mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +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
|
|
@ -223,6 +223,7 @@ dependencies {
|
|||
implementation(libs.androidx.tv.foundation)
|
||||
implementation(libs.androidx.tv.material)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.livedata.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation(libs.protobuf.kotlin.lite)
|
||||
|
|
@ -299,5 +300,7 @@ dependencies {
|
|||
|
||||
testImplementation(libs.mockk.android)
|
||||
testImplementation(libs.mockk.agent)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.androidx.core.testing)
|
||||
testImplementation(libs.robolectric)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.Response
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SuggestionServiceTest {
|
||||
@get:Rule
|
||||
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
private val mockApi = mockk<ApiClient>(relaxed = true)
|
||||
private val mockServerRepository = mockk<ServerRepository>()
|
||||
private val mockCache = mockk<SuggestionsCache>()
|
||||
private val mockWorkManager = mockk<WorkManager>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
io.mockk.unmockkObject(GetItemsRequestHandler)
|
||||
}
|
||||
|
||||
private fun createService() =
|
||||
SuggestionService(
|
||||
api = mockApi,
|
||||
serverRepository = mockServerRepository,
|
||||
cache = mockCache,
|
||||
workManager = mockWorkManager,
|
||||
)
|
||||
|
||||
private fun mockQueryResult(items: List<BaseItemDto>): Response<BaseItemDtoQueryResult> =
|
||||
mockk {
|
||||
every { content } returns
|
||||
mockk {
|
||||
every { this@mockk.items } returns items
|
||||
}
|
||||
}
|
||||
|
||||
private fun mockUser(id: UUID = UUID.randomUUID()): JellyfinUser =
|
||||
JellyfinUser(
|
||||
id = id,
|
||||
name = "TestUser",
|
||||
serverId = UUID.randomUUID(),
|
||||
accessToken = "token",
|
||||
)
|
||||
|
||||
private fun mockWorkInfo(state: WorkInfo.State): WorkInfo = mockk<WorkInfo> { every { this@mockk.state } returns state }
|
||||
|
||||
@Test
|
||||
fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() =
|
||||
runTest {
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(null)
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(UUID.randomUUID(), BaseItemKind.MOVIE).first()
|
||||
|
||||
assertEquals(SuggestionsResource.Empty, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun maps_active_work_states_to_Loading() =
|
||||
runTest {
|
||||
listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED).forEach { state ->
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
|
||||
flowOf(listOf(mockWorkInfo(state)))
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
|
||||
|
||||
assertEquals(SuggestionsResource.Loading, result)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun maps_finished_work_states_to_Empty() =
|
||||
runTest {
|
||||
listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED).forEach { state ->
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns
|
||||
flowOf(listOf(mockWorkInfo(state)))
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
|
||||
|
||||
assertEquals(SuggestionsResource.Empty, result)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getSuggestionsFlow_returnsEmpty_whenCacheEmptyAndNoWorkInfo() =
|
||||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
|
||||
|
||||
assertEquals(SuggestionsResource.Empty, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passes_correct_arguments_to_cache() =
|
||||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val libraryId = UUID.randomUUID()
|
||||
val otherLibraryId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null
|
||||
coEvery {
|
||||
mockCache.get(
|
||||
userId,
|
||||
otherLibraryId,
|
||||
BaseItemKind.MOVIE,
|
||||
)
|
||||
} returns CachedSuggestions(listOf(UUID.randomUUID()))
|
||||
|
||||
val service = createService()
|
||||
assertEquals(SuggestionsResource.Empty, service.getSuggestionsFlow(libraryId, BaseItemKind.MOVIE).first())
|
||||
|
||||
coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null
|
||||
coEvery {
|
||||
mockCache.get(
|
||||
userId,
|
||||
libraryId,
|
||||
BaseItemKind.SERIES,
|
||||
)
|
||||
} returns CachedSuggestions(listOf(UUID.randomUUID()))
|
||||
|
||||
assertEquals(SuggestionsResource.Empty, service.getSuggestionsFlow(libraryId, BaseItemKind.MOVIE).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getSuggestionsFlow_returnsSuccess_whenCacheHasItems() =
|
||||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
|
||||
val cachedId = UUID.randomUUID()
|
||||
coEvery {
|
||||
mockCache.get(
|
||||
userId,
|
||||
parentId,
|
||||
BaseItemKind.MOVIE,
|
||||
)
|
||||
} returns CachedSuggestions(listOf(cachedId))
|
||||
|
||||
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))
|
||||
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
|
||||
|
||||
assertTrue(result is SuggestionsResource.Success)
|
||||
val items = (result as SuggestionsResource.Success).items
|
||||
assertEquals(1, items.size)
|
||||
assertEquals(cachedId, items[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getSuggestionsFlow_emitsEmpty_whenApiFails() =
|
||||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val parentId = UUID.randomUUID()
|
||||
val currentUser = MutableLiveData<JellyfinUser?>(mockUser(userId))
|
||||
|
||||
every { mockServerRepository.currentUser } returns currentUser
|
||||
every { mockCache.cacheVersion } returns MutableStateFlow(0L)
|
||||
|
||||
val cachedId = UUID.randomUUID()
|
||||
coEvery {
|
||||
mockCache.get(
|
||||
userId,
|
||||
parentId,
|
||||
BaseItemKind.MOVIE,
|
||||
)
|
||||
} returns CachedSuggestions(listOf(cachedId))
|
||||
|
||||
io.mockk.mockkObject(GetItemsRequestHandler)
|
||||
coEvery { GetItemsRequestHandler.execute(mockApi, any()) } throws RuntimeException("Network error")
|
||||
|
||||
every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList())
|
||||
|
||||
val service = createService()
|
||||
val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first()
|
||||
|
||||
assertEquals(SuggestionsResource.Empty, result)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
import kotlin.io.path.createTempDirectory
|
||||
|
||||
class SuggestionsCacheTest {
|
||||
private val tempDir = createTempDirectory("suggestions-cache-test").toFile()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun testCacheWithTempDir(): SuggestionsCache {
|
||||
val mockContext = mockk<Context>(relaxed = true)
|
||||
every { mockContext.cacheDir } returns tempDir
|
||||
return SuggestionsCache(mockContext)
|
||||
}
|
||||
|
||||
private fun memoryCacheOf(cache: SuggestionsCache): MutableMap<String, CachedSuggestions> {
|
||||
val field = SuggestionsCache::class.java.getDeclaredField("memoryCache")
|
||||
field.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return field.get(cache) as MutableMap<String, CachedSuggestions>
|
||||
}
|
||||
|
||||
@Test
|
||||
fun putThenGet_returnsCachedSuggestions() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
val libId = UUID.randomUUID()
|
||||
|
||||
cache.put(userId, libId, BaseItemKind.MOVIE, emptyList())
|
||||
|
||||
val loaded = cache.get(userId, libId, BaseItemKind.MOVIE)
|
||||
assertNotNull(loaded)
|
||||
assertEquals(0, loaded!!.ids.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun get_readsFromDisk_whenMemoryAbsent() =
|
||||
runTest {
|
||||
val cache1 = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
val libId = UUID.randomUUID()
|
||||
|
||||
cache1.put(userId, libId, BaseItemKind.MOVIE, emptyList())
|
||||
cache1.save()
|
||||
|
||||
// Create a fresh instance which won't have the memory entry
|
||||
val cache2 = testCacheWithTempDir()
|
||||
// memoryCache should be empty
|
||||
assertTrue(memoryCacheOf(cache2).isEmpty())
|
||||
|
||||
val loaded = cache2.get(userId, libId, BaseItemKind.MOVIE)
|
||||
assertNotNull(loaded)
|
||||
assertEquals(0, loaded!!.ids.size)
|
||||
// After read, memory cache should contain the entry
|
||||
assertTrue(memoryCacheOf(cache2).isNotEmpty())
|
||||
}
|
||||
|
||||
// LRU behavior is not enforced in production; keep tests focused on public behavior.
|
||||
@Test
|
||||
fun memoryCache_respectsLruLimit() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
|
||||
// Insert MAX + 2 entries and ensure size never exceeds limit
|
||||
val limit = 8 // keep in sync with implementation
|
||||
val libIds = mutableListOf<UUID>()
|
||||
for (i in 0 until (limit + 2)) {
|
||||
val libId = UUID.randomUUID()
|
||||
libIds.add(libId)
|
||||
cache.put(userId, libId, BaseItemKind.MOVIE, emptyList())
|
||||
}
|
||||
|
||||
// memoryCache should be bounded to the limit
|
||||
val mem = memoryCacheOf(cache)
|
||||
assertTrue(mem.size <= limit)
|
||||
// The oldest (first inserted) should be evicted from memory cache
|
||||
val firstKey = "${userId}_${libIds.first()}_${BaseItemKind.MOVIE.serialName}"
|
||||
assertFalse(mem.containsKey(firstKey))
|
||||
}
|
||||
|
||||
// Library isolation tests - verify different parentIds/itemKinds don't mix
|
||||
|
||||
@Test
|
||||
fun differentParentIds_returnIsolatedCacheEntries() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
val movieLibraryId = UUID.randomUUID()
|
||||
val tvLibraryId = UUID.randomUUID()
|
||||
|
||||
val movieIds = List(2) { UUID.randomUUID() }
|
||||
val tvIds = List(3) { UUID.randomUUID() }
|
||||
|
||||
cache.put(userId, movieLibraryId, BaseItemKind.MOVIE, movieIds)
|
||||
cache.put(userId, tvLibraryId, BaseItemKind.MOVIE, tvIds)
|
||||
|
||||
val loadedMovies = cache.get(userId, movieLibraryId, BaseItemKind.MOVIE)
|
||||
val loadedTv = cache.get(userId, tvLibraryId, BaseItemKind.MOVIE)
|
||||
|
||||
assertNotNull(loadedMovies)
|
||||
assertNotNull(loadedTv)
|
||||
assertEquals(2, loadedMovies!!.ids.size)
|
||||
assertEquals(3, loadedTv!!.ids.size)
|
||||
assertEquals(movieIds[0], loadedMovies.ids[0])
|
||||
assertEquals(tvIds[0], loadedTv.ids[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentItemKinds_returnIsolatedCacheEntries() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
val libraryId = UUID.randomUUID()
|
||||
|
||||
val movieIds = listOf(UUID.randomUUID())
|
||||
val seriesIds = List(2) { UUID.randomUUID() }
|
||||
|
||||
cache.put(userId, libraryId, BaseItemKind.MOVIE, movieIds)
|
||||
cache.put(userId, libraryId, BaseItemKind.SERIES, seriesIds)
|
||||
|
||||
val loadedMovies = cache.get(userId, libraryId, BaseItemKind.MOVIE)
|
||||
val loadedSeries = cache.get(userId, libraryId, BaseItemKind.SERIES)
|
||||
|
||||
assertNotNull(loadedMovies)
|
||||
assertNotNull(loadedSeries)
|
||||
assertEquals(1, loadedMovies!!.ids.size)
|
||||
assertEquals(2, loadedSeries!!.ids.size)
|
||||
assertEquals(movieIds[0], loadedMovies.ids[0])
|
||||
assertEquals(seriesIds[0], loadedSeries.ids[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rapidLibrarySwitching_maintainsIsolation() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val userId = UUID.randomUUID()
|
||||
val lib1 = UUID.randomUUID()
|
||||
val lib2 = UUID.randomUUID()
|
||||
val lib3 = UUID.randomUUID()
|
||||
|
||||
val ids1 = listOf(UUID.randomUUID())
|
||||
val ids2 = listOf(UUID.randomUUID())
|
||||
val ids3 = listOf(UUID.randomUUID())
|
||||
|
||||
// Simulate rapid switching: put -> get -> put -> get pattern
|
||||
cache.put(userId, lib1, BaseItemKind.MOVIE, ids1)
|
||||
assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
|
||||
cache.put(userId, lib2, BaseItemKind.MOVIE, ids2)
|
||||
assertEquals(ids2[0], cache.get(userId, lib2, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
|
||||
// Switch back to lib1 - should still have correct data
|
||||
assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
|
||||
cache.put(userId, lib3, BaseItemKind.MOVIE, ids3)
|
||||
assertEquals(ids3[0], cache.get(userId, lib3, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
|
||||
// Verify all libraries still have correct data after rapid switching
|
||||
assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
assertEquals(ids2[0], cache.get(userId, lib2, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
assertEquals(ids3[0], cache.get(userId, lib3, BaseItemKind.MOVIE)?.ids?.firstOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun libraryIsolation_persistsToDisk() =
|
||||
runTest {
|
||||
val userId = UUID.randomUUID()
|
||||
val lib1 = UUID.randomUUID()
|
||||
val lib2 = UUID.randomUUID()
|
||||
|
||||
val ids1 = listOf(UUID.randomUUID())
|
||||
val ids2 = listOf(UUID.randomUUID())
|
||||
|
||||
// Write with first cache instance
|
||||
val cache1 = testCacheWithTempDir()
|
||||
cache1.put(userId, lib1, BaseItemKind.MOVIE, ids1)
|
||||
cache1.put(userId, lib2, BaseItemKind.SERIES, ids2)
|
||||
cache1.save()
|
||||
|
||||
// Read with fresh cache instance (empty memory cache, reads from disk)
|
||||
val cache2 = testCacheWithTempDir()
|
||||
assertTrue(memoryCacheOf(cache2).isEmpty())
|
||||
|
||||
val loaded1 = cache2.get(userId, lib1, BaseItemKind.MOVIE)
|
||||
val loaded2 = cache2.get(userId, lib2, BaseItemKind.SERIES)
|
||||
|
||||
assertNotNull(loaded1)
|
||||
assertNotNull(loaded2)
|
||||
assertEquals(ids1[0], loaded1!!.ids[0])
|
||||
assertEquals(ids2[0], loaded2!!.ids[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun differentUsers_returnIsolatedCacheEntries() =
|
||||
runTest {
|
||||
val cache = testCacheWithTempDir()
|
||||
val user1 = UUID.randomUUID()
|
||||
val user2 = UUID.randomUUID()
|
||||
val libraryId = UUID.randomUUID()
|
||||
|
||||
val user1Ids = listOf(UUID.randomUUID())
|
||||
val user2Ids = List(2) { UUID.randomUUID() }
|
||||
|
||||
cache.put(user1, libraryId, BaseItemKind.MOVIE, user1Ids)
|
||||
cache.put(user2, libraryId, BaseItemKind.MOVIE, user2Ids)
|
||||
|
||||
val loadedUser1 = cache.get(user1, libraryId, BaseItemKind.MOVIE)
|
||||
val loadedUser2 = cache.get(user2, libraryId, BaseItemKind.MOVIE)
|
||||
|
||||
assertNotNull(loadedUser1)
|
||||
assertNotNull(loadedUser2)
|
||||
assertEquals(1, loadedUser1!!.ids.size)
|
||||
assertEquals(2, loadedUser2!!.ids.size)
|
||||
assertEquals(user1Ids[0], loadedUser1.ids[0])
|
||||
assertEquals(user2Ids[0], loadedUser2.ids[0])
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
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 com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import io.mockk.coEvery
|
||||
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
|
||||
import java.util.UUID
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SuggestionsSchedulerServiceTest {
|
||||
@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 mockCache = mockk<SuggestionsCache>(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() =
|
||||
SuggestionsSchedulerService(
|
||||
context = mockActivity,
|
||||
serverRepository = mockServerRepository,
|
||||
cache = mockCache,
|
||||
workManager = mockWorkManager,
|
||||
).also { it.dispatcher = testDispatcher }
|
||||
|
||||
@Test
|
||||
fun schedules_periodic_work_when_user_present() =
|
||||
runTest {
|
||||
coEvery { mockCache.isEmpty() } returns false
|
||||
createService()
|
||||
currentLiveData.value =
|
||||
CurrentUser(
|
||||
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancels_work_when_user_null() =
|
||||
runTest {
|
||||
coEvery { mockCache.isEmpty() } returns false
|
||||
createService()
|
||||
currentLiveData.value =
|
||||
CurrentUser(
|
||||
user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
currentLiveData.value = null
|
||||
advanceUntilIdle()
|
||||
verify { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.work.Data
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.github.damontecres.wholphin.data.CurrentUser
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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.api.operations.UserViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
class SuggestionsWorkerTest {
|
||||
private val testUserId = UUID.randomUUID()
|
||||
private val testServerId = UUID.randomUUID()
|
||||
private val mockWorkerParams = mockk<WorkerParameters>(relaxed = true)
|
||||
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
|
||||
private val mockPreferences = mockk<DataStore<AppPreferences>>(relaxed = true)
|
||||
private val mockApi = mockk<ApiClient>(relaxed = true)
|
||||
private val mockCache = mockk<SuggestionsCache>(relaxed = true)
|
||||
private val mockUserViewsApi = mockk<UserViewsApi>(relaxed = true)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
every { mockApi.userViewsApi } returns mockUserViewsApi
|
||||
every { mockApi.baseUrl } returns "http://localhost"
|
||||
every { mockApi.accessToken } returns "test-token"
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = unmockkObject(GetItemsRequestHandler)
|
||||
|
||||
private fun createWorker(
|
||||
userId: UUID? = testUserId,
|
||||
serverId: UUID? = testServerId,
|
||||
): SuggestionsWorker {
|
||||
val inputData =
|
||||
Data
|
||||
.Builder()
|
||||
.apply {
|
||||
userId?.let { putString(SuggestionsWorker.PARAM_USER_ID, it.toString()) }
|
||||
serverId?.let { putString(SuggestionsWorker.PARAM_SERVER_ID, it.toString()) }
|
||||
}.build()
|
||||
every { mockWorkerParams.inputData } returns inputData
|
||||
return SuggestionsWorker(
|
||||
context = mockk(relaxed = true),
|
||||
workerParams = mockWorkerParams,
|
||||
serverRepository = mockServerRepository,
|
||||
preferences = mockPreferences,
|
||||
api = mockApi,
|
||||
cache = mockCache,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mockPrefs() =
|
||||
mockk<AppPreferences>(relaxed = true) {
|
||||
every { homePagePreferences } returns mockk { every { maxItemsPerRow } returns 25 }
|
||||
}
|
||||
|
||||
private fun mockQueryResult(items: List<BaseItemDto> = emptyList()) =
|
||||
mockk<org.jellyfin.sdk.api.client.Response<BaseItemDtoQueryResult>>(relaxed = true) {
|
||||
every { content } returns mockk { every { this@mockk.items } returns items }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returns_failure_on_invalid_input() =
|
||||
runTest {
|
||||
listOf(
|
||||
createWorker(userId = null),
|
||||
createWorker(serverId = null),
|
||||
).forEach { worker ->
|
||||
assertEquals(ListenableWorker.Result.failure(), worker.doWork())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restores_session_when_api_not_configured() =
|
||||
runTest {
|
||||
every { mockApi.baseUrl } returns null
|
||||
every { mockApi.accessToken } returns null
|
||||
val mockUser = mockk<CurrentUser>()
|
||||
var restored = false
|
||||
every { mockServerRepository.current } answers { MutableLiveData(if (restored) mockUser else null) }
|
||||
coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers {
|
||||
restored = true
|
||||
mockUser
|
||||
}
|
||||
every { mockPreferences.data } returns flowOf(mockPrefs())
|
||||
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult()
|
||||
|
||||
val result = createWorker().doWork()
|
||||
|
||||
coVerify { mockServerRepository.restoreSession(testServerId, testUserId) }
|
||||
assertEquals(ListenableWorker.Result.success(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun caches_suggestions_for_supported_types() =
|
||||
runTest {
|
||||
listOf(
|
||||
CollectionType.MOVIES to BaseItemKind.MOVIE,
|
||||
CollectionType.TVSHOWS to BaseItemKind.SERIES,
|
||||
).forEach { (collectionType, itemKind) ->
|
||||
val viewId = UUID.randomUUID()
|
||||
val view =
|
||||
mockk<BaseItemDto>(relaxed = true) {
|
||||
every { id } returns viewId
|
||||
every { this@mockk.collectionType } returns collectionType
|
||||
}
|
||||
every { mockPreferences.data } returns flowOf(mockPrefs())
|
||||
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view))
|
||||
mockkObject(GetItemsRequestHandler)
|
||||
coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(mockk(relaxed = true)))
|
||||
|
||||
val result = createWorker().doWork()
|
||||
|
||||
assertEquals(ListenableWorker.Result.success(), result)
|
||||
coVerify { mockCache.put(testUserId, viewId, itemKind, any()) }
|
||||
coVerify { mockCache.save() }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returns_retry_on_network_error() =
|
||||
runTest {
|
||||
every { mockPreferences.data } returns flowOf(mockPrefs())
|
||||
coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } throws ApiClientException("Network error")
|
||||
|
||||
val result = createWorker().doWork()
|
||||
|
||||
assertEquals(ListenableWorker.Result.retry(), result)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.NameGuidPair
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
|
||||
/**
|
||||
* Tests for the suggestions deduplication and genre collection logic used in
|
||||
* RecommendedMovie and RecommendedTvShow ViewModels.
|
||||
*/
|
||||
class TestSuggestionsDeduplication {
|
||||
@Test
|
||||
fun `deduplication by seriesId groups episodes of same series`() {
|
||||
val seriesId = UUID.randomUUID()
|
||||
val items =
|
||||
listOf(
|
||||
episode(seriesId = seriesId, name = "Episode 1"),
|
||||
episode(seriesId = seriesId, name = "Episode 2"),
|
||||
episode(seriesId = seriesId, name = "Episode 3"),
|
||||
movie(name = "Some Movie"),
|
||||
)
|
||||
|
||||
val deduplicated =
|
||||
items
|
||||
.distinctBy { it.seriesId ?: it.id }
|
||||
.take(3)
|
||||
|
||||
// Should have 2 items: one series entry and one movie
|
||||
assertEquals(2, deduplicated.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deduplication preserves order - most recent first`() {
|
||||
val series1 = UUID.randomUUID()
|
||||
val series2 = UUID.randomUUID()
|
||||
val items =
|
||||
listOf(
|
||||
episode(seriesId = series1, name = "S1 Episode 1"), // Most recent
|
||||
episode(seriesId = series1, name = "S1 Episode 2"),
|
||||
episode(seriesId = series2, name = "S2 Episode 1"),
|
||||
movie(name = "Old Movie"),
|
||||
)
|
||||
|
||||
val deduplicated =
|
||||
items
|
||||
.distinctBy { it.seriesId ?: it.id }
|
||||
.take(3)
|
||||
|
||||
assertEquals(3, deduplicated.size)
|
||||
// First item should be from series1 (most recent)
|
||||
assertEquals(series1, deduplicated[0].seriesId)
|
||||
assertEquals(series2, deduplicated[1].seriesId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `movies use their own id for deduplication`() {
|
||||
val items =
|
||||
listOf(
|
||||
movie(name = "Movie 1"),
|
||||
movie(name = "Movie 2"),
|
||||
movie(name = "Movie 3"),
|
||||
)
|
||||
|
||||
val deduplicated =
|
||||
items
|
||||
.distinctBy { it.seriesId ?: it.id }
|
||||
.take(3)
|
||||
|
||||
// All movies should be kept since they have unique IDs
|
||||
assertEquals(3, deduplicated.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `take 3 limits seed items`() {
|
||||
val items =
|
||||
listOf(
|
||||
movie(name = "Movie 1"),
|
||||
movie(name = "Movie 2"),
|
||||
movie(name = "Movie 3"),
|
||||
movie(name = "Movie 4"),
|
||||
movie(name = "Movie 5"),
|
||||
)
|
||||
|
||||
val deduplicated =
|
||||
items
|
||||
.distinctBy { it.seriesId ?: it.id }
|
||||
.take(3)
|
||||
|
||||
assertEquals(3, deduplicated.size)
|
||||
}
|
||||
}
|
||||
|
||||
class TestSuggestionsGenreCollection {
|
||||
@Test
|
||||
fun `collects genres from multiple seed items`() {
|
||||
val genre1 = NameGuidPair(id = UUID.randomUUID(), name = "Action")
|
||||
val genre2 = NameGuidPair(id = UUID.randomUUID(), name = "Comedy")
|
||||
val genre3 = NameGuidPair(id = UUID.randomUUID(), name = "Drama")
|
||||
|
||||
val seedItems =
|
||||
listOf(
|
||||
movie(name = "Action Movie", genres = listOf(genre1)),
|
||||
movie(name = "Comedy Movie", genres = listOf(genre2)),
|
||||
movie(name = "Drama Movie", genres = listOf(genre3)),
|
||||
)
|
||||
|
||||
val allGenreIds =
|
||||
seedItems
|
||||
.flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() }
|
||||
.distinct()
|
||||
|
||||
assertEquals(3, allGenreIds.size)
|
||||
assertTrue(allGenreIds.contains(genre1.id))
|
||||
assertTrue(allGenreIds.contains(genre2.id))
|
||||
assertTrue(allGenreIds.contains(genre3.id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deduplicates shared genres across seed items`() {
|
||||
val sharedGenre = NameGuidPair(id = UUID.randomUUID(), name = "Action")
|
||||
val uniqueGenre = NameGuidPair(id = UUID.randomUUID(), name = "Comedy")
|
||||
|
||||
val seedItems =
|
||||
listOf(
|
||||
movie(name = "Action Movie 1", genres = listOf(sharedGenre)),
|
||||
movie(name = "Action Movie 2", genres = listOf(sharedGenre)),
|
||||
movie(name = "Action Comedy", genres = listOf(sharedGenre, uniqueGenre)),
|
||||
)
|
||||
|
||||
val allGenreIds =
|
||||
seedItems
|
||||
.flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() }
|
||||
.distinct()
|
||||
|
||||
// Should only have 2 unique genres
|
||||
assertEquals(2, allGenreIds.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles items with no genres`() {
|
||||
val genre1 = NameGuidPair(id = UUID.randomUUID(), name = "Action")
|
||||
|
||||
val seedItems =
|
||||
listOf(
|
||||
movie(name = "Movie with genre", genres = listOf(genre1)),
|
||||
movie(name = "Movie without genre", genres = null),
|
||||
movie(name = "Movie with empty genres", genres = emptyList()),
|
||||
)
|
||||
|
||||
val allGenreIds =
|
||||
seedItems
|
||||
.flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() }
|
||||
.distinct()
|
||||
|
||||
assertEquals(1, allGenreIds.size)
|
||||
assertEquals(genre1.id, allGenreIds[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns empty list when no seed items have genres`() {
|
||||
val seedItems =
|
||||
listOf(
|
||||
movie(name = "Movie 1", genres = null),
|
||||
movie(name = "Movie 2", genres = emptyList()),
|
||||
)
|
||||
|
||||
val allGenreIds =
|
||||
seedItems
|
||||
.flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() }
|
||||
.distinct()
|
||||
|
||||
assertTrue(allGenreIds.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
class TestSuggestionsExcludeIds {
|
||||
@Test
|
||||
fun `excludeIds contains all seed item ids`() {
|
||||
val seedItems =
|
||||
listOf(
|
||||
movie(name = "Movie 1"),
|
||||
movie(name = "Movie 2"),
|
||||
movie(name = "Movie 3"),
|
||||
)
|
||||
|
||||
val excludeIds = seedItems.map { it.id }
|
||||
|
||||
assertEquals(3, excludeIds.size)
|
||||
seedItems.forEach { item ->
|
||||
assertTrue(excludeIds.contains(item.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RunWith(Parameterized::class)
|
||||
class TestSuggestionsCombineAndDeduplicate(
|
||||
private val contextual: List<BaseItemDto>,
|
||||
private val random: List<BaseItemDto>,
|
||||
private val fresh: List<BaseItemDto>,
|
||||
private val expectedUniqueCount: Int,
|
||||
private val description: String,
|
||||
) {
|
||||
@Test
|
||||
fun `combine and deduplicate works correctly`() {
|
||||
val combined =
|
||||
(contextual + random + fresh)
|
||||
.distinctBy { it.id }
|
||||
|
||||
assertEquals(description, expectedUniqueCount, combined.size)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "{4}")
|
||||
fun data(): Collection<Array<Any>> {
|
||||
val movie1 = movie(name = "Movie 1")
|
||||
val movie2 = movie(name = "Movie 2")
|
||||
val movie3 = movie(name = "Movie 3")
|
||||
val movie4 = movie(name = "Movie 4")
|
||||
|
||||
return listOf(
|
||||
arrayOf(
|
||||
listOf(movie1, movie2),
|
||||
listOf(movie3),
|
||||
listOf(movie4),
|
||||
4,
|
||||
"no duplicates - all 4 unique",
|
||||
),
|
||||
arrayOf(
|
||||
listOf(movie1, movie2),
|
||||
listOf(movie1, movie3),
|
||||
listOf(movie2, movie4),
|
||||
4,
|
||||
"with duplicates - deduplicates to 4",
|
||||
),
|
||||
arrayOf(
|
||||
listOf(movie1),
|
||||
listOf(movie1),
|
||||
listOf(movie1),
|
||||
1,
|
||||
"all same - deduplicates to 1",
|
||||
),
|
||||
arrayOf(
|
||||
emptyList<BaseItemDto>(),
|
||||
listOf(movie1, movie2),
|
||||
listOf(movie3),
|
||||
3,
|
||||
"empty contextual - still combines others",
|
||||
),
|
||||
arrayOf(
|
||||
emptyList<BaseItemDto>(),
|
||||
emptyList<BaseItemDto>(),
|
||||
emptyList<BaseItemDto>(),
|
||||
0,
|
||||
"all empty - returns empty",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions to create test data
|
||||
|
||||
private fun movie(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
name: String = "Test Movie",
|
||||
genres: List<NameGuidPair>? = null,
|
||||
): BaseItemDto =
|
||||
BaseItemDto(
|
||||
id = id,
|
||||
type = BaseItemKind.MOVIE,
|
||||
name = name,
|
||||
seriesId = null,
|
||||
genreItems = genres,
|
||||
)
|
||||
|
||||
private fun episode(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
seriesId: UUID,
|
||||
name: String = "Test Episode",
|
||||
genres: List<NameGuidPair>? = null,
|
||||
): BaseItemDto =
|
||||
BaseItemDto(
|
||||
id = id,
|
||||
type = BaseItemKind.EPISODE,
|
||||
name = name,
|
||||
seriesId = seriesId,
|
||||
genreItems = genres,
|
||||
)
|
||||
|
|
@ -41,6 +41,8 @@ preferenceKtx = "1.2.1"
|
|||
tvprovider = "1.1.0"
|
||||
workRuntimeKtx = "2.11.1"
|
||||
paletteKtx = "1.0.0"
|
||||
kotlinxCoroutinesTest = "1.7.3"
|
||||
coreTesting = "2.2.0"
|
||||
openapi-generator = "7.19.0"
|
||||
|
||||
[libraries]
|
||||
|
|
@ -67,6 +69,7 @@ androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-com
|
|||
androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
|
||||
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
||||
androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" }
|
||||
|
|
@ -124,6 +127,8 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
|
|||
androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" }
|
||||
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
|
||||
androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
|
||||
androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue