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:
Justin Caveda 2026-02-04 19:50:29 -06:00 committed by GitHub
parent d9d5ab02e8
commit aeaecc0f59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1809 additions and 76 deletions

View file

@ -223,6 +223,7 @@ dependencies {
implementation(libs.androidx.tv.foundation) implementation(libs.androidx.tv.foundation)
implementation(libs.androidx.tv.material) implementation(libs.androidx.tv.material)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.datastore) implementation(libs.androidx.datastore)
implementation(libs.protobuf.kotlin.lite) implementation(libs.protobuf.kotlin.lite)
@ -299,5 +300,7 @@ dependencies {
testImplementation(libs.mockk.android) testImplementation(libs.mockk.android)
testImplementation(libs.mockk.agent) testImplementation(libs.mockk.agent)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.core.testing)
testImplementation(libs.robolectric) testImplementation(libs.robolectric)
} }

View file

@ -52,6 +52,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.ServerEventListener import com.github.damontecres.wholphin.services.ServerEventListener
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager 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.UpdateChecker
import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.UserSwitchListener
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
@ -119,6 +120,9 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var tvProviderSchedulerService: TvProviderSchedulerService lateinit var tvProviderSchedulerService: TvProviderSchedulerService
@Inject
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
// Note: unused but injected to ensure it is created // Note: unused but injected to ensure it is created
@Inject @Inject
lateinit var serverEventListener: ServerEventListener lateinit var serverEventListener: ServerEventListener

View file

@ -4,6 +4,9 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import dagger.hilt.android.scopes.ActivityRetainedScoped import dagger.hilt.android.scopes.ActivityRetainedScoped
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -16,6 +19,7 @@ class PlaybackLifecycleObserver
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
private val playerFactory: PlayerFactory, private val playerFactory: PlayerFactory,
private val themeSongPlayer: ThemeSongPlayer, private val themeSongPlayer: ThemeSongPlayer,
private val suggestionsCache: SuggestionsCache,
) : DefaultLifecycleObserver { ) : DefaultLifecycleObserver {
private var wasPlaying: Boolean? = null private var wasPlaying: Boolean? = null
@ -48,5 +52,6 @@ class PlaybackLifecycleObserver
override fun onStop(owner: LifecycleOwner) { override fun onStop(owner: LifecycleOwner) {
themeSongPlayer.stop() themeSongPlayer.stop()
CoroutineScope(Dispatchers.Main).launch { suggestionsCache.save() }
} }
} }

View file

@ -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) }
}
}

View file

@ -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
}
}

View file

@ -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(),
)
}
}

View file

@ -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
}
}

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services.hilt
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.work.WorkManager
import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
@ -176,6 +177,12 @@ object AppModule {
@IoCoroutineScope @IoCoroutineScope
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
fun workManager(
@ApplicationContext context: Context,
): WorkManager = WorkManager.getInstance(context)
@Provides @Provides
@Singleton @Singleton
fun seerrApi( fun seerrApi(

View file

@ -29,9 +29,9 @@ class TvProviderSchedulerService
constructor( constructor(
@param:ActivityContext private val context: Context, @param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val workManager: WorkManager,
) { ) {
private val activity = (context as AppCompatActivity) private val activity = (context as AppCompatActivity)
private val workManager = WorkManager.getInstance(context)
private val supportsTvProvider = private val supportsTvProvider =
// TODO <=25 has limited support // TODO <=25 has limited support

View file

@ -15,6 +15,8 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager 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.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.setValueOnMain 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.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler 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.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted 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.SortOrder
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -54,6 +54,7 @@ class RecommendedMovieViewModel
private val api: ApiClient, private val api: ApiClient,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>, private val preferencesDataStore: DataStore<AppPreferences>,
private val suggestionService: SuggestionService,
@Assisted val parentId: UUID, @Assisted val parentId: UUID,
navigationManager: NavigationManager, navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager, favoriteWatchManager: FavoriteWatchManager,
@ -115,7 +116,7 @@ class RecommendedMovieViewModel
} }
update(R.string.recently_released) { update(R.string.recently_released) {
val recentlyReleasedRequest = val request =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
@ -128,13 +129,11 @@ class RecommendedMovieViewModel
limit = itemsPerRow, limit = itemsPerRow,
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
.execute(api, recentlyReleasedRequest)
.toBaseItems(api, false)
} }
update(R.string.recently_added) { update(R.string.recently_added) {
val recentlyAddedRequest = val request =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
@ -147,27 +146,11 @@ class RecommendedMovieViewModel
limit = itemsPerRow, limit = itemsPerRow,
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
.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)
} }
update(R.string.top_unwatched) { update(R.string.top_unwatched) {
val unwatchedTopRatedRequest = val request =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
@ -181,9 +164,48 @@ class RecommendedMovieViewModel
limit = itemsPerRow, limit = itemsPerRow,
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler GetItemsRequestHandler.execute(api, request).toBaseItems(api, false)
.execute(api, unwatchedTopRatedRequest) }
.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) { if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {

View file

@ -15,6 +15,8 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.NavigationManager 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.SlimItemFields
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.setValueOnMain 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.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetNextUpRequestHandler import com.github.damontecres.wholphin.util.GetNextUpRequestHandler
import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler 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.HomeRowLoadingState
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.assisted.Assisted 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.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -58,6 +58,7 @@ class RecommendedTvShowViewModel
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>, private val preferencesDataStore: DataStore<AppPreferences>,
private val lastestNextUpService: LatestNextUpService, private val lastestNextUpService: LatestNextUpService,
private val suggestionService: SuggestionService,
@Assisted val parentId: UUID, @Assisted val parentId: UUID,
navigationManager: NavigationManager, navigationManager: NavigationManager,
favoriteWatchManager: FavoriteWatchManager, favoriteWatchManager: FavoriteWatchManager,
@ -86,7 +87,7 @@ class RecommendedTvShowViewModel
val userId = serverRepository.currentUser.value?.id val userId = serverRepository.currentUser.value?.id
try { try {
val resumeItemsDeferred = val resumeItemsDeferred =
viewModelScope.async(Dispatchers.IO) { async(Dispatchers.IO) {
val resumeItemsRequest = val resumeItemsRequest =
GetResumeItemsRequest( GetResumeItemsRequest(
userId = userId, userId = userId,
@ -104,7 +105,7 @@ class RecommendedTvShowViewModel
} }
val nextUpItemsDeferred = val nextUpItemsDeferred =
viewModelScope.async(Dispatchers.IO) { async(Dispatchers.IO) {
val nextUpRequest = val nextUpRequest =
GetNextUpRequest( GetNextUpRequest(
userId = userId, userId = userId,
@ -116,19 +117,16 @@ class RecommendedTvShowViewModel
enableUserData = true, enableUserData = true,
enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp, enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp,
) )
GetNextUpRequestHandler GetNextUpRequestHandler
.execute(api, nextUpRequest) .execute(api, nextUpRequest)
.toBaseItems(api, true) .toBaseItems(api, true)
} }
val resumeItems = resumeItemsDeferred.await() val resumeItems = resumeItemsDeferred.await()
val nextUpItems = nextUpItemsDeferred.await() val nextUpItems = nextUpItemsDeferred.await()
if (combineNextUp) { if (combineNextUp) {
val combined = val combined = lastestNextUpService.buildCombined(resumeItems, nextUpItems)
lastestNextUpService.buildCombined(
resumeItems,
nextUpItems,
)
update( update(
R.string.continue_watching, R.string.continue_watching,
HomeRowLoadingState.Success( HomeRowLoadingState.Success(
@ -138,10 +136,7 @@ class RecommendedTvShowViewModel
) )
update( update(
R.string.next_up, R.string.next_up,
HomeRowLoadingState.Success( HomeRowLoadingState.Success(context.getString(R.string.next_up), listOf()),
context.getString(R.string.next_up),
listOf(),
),
) )
} else { } else {
update( update(
@ -153,10 +148,7 @@ class RecommendedTvShowViewModel
) )
update( update(
R.string.next_up, R.string.next_up,
HomeRowLoadingState.Success( HomeRowLoadingState.Success(context.getString(R.string.next_up), nextUpItems),
context.getString(R.string.next_up),
nextUpItems,
),
) )
} }
@ -171,7 +163,7 @@ class RecommendedTvShowViewModel
} }
update(R.string.recently_released) { update(R.string.recently_released) {
val recentlyReleasedRequest = val request =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
@ -184,14 +176,11 @@ class RecommendedTvShowViewModel
limit = itemsPerRow, limit = itemsPerRow,
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
GetItemsRequestHandler
.execute(api, recentlyReleasedRequest)
.toBaseItems(api, true)
} }
update(R.string.recently_added) { update(R.string.recently_added) {
val recentlyAddedRequest = val request =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
@ -204,29 +193,11 @@ class RecommendedTvShowViewModel
limit = itemsPerRow, limit = itemsPerRow,
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
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)
} }
update(R.string.top_unwatched) { update(R.string.top_unwatched) {
val unwatchedTopRatedRequest = val request =
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
fields = SlimItemFields, fields = SlimItemFields,
@ -240,9 +211,48 @@ class RecommendedTvShowViewModel
limit = itemsPerRow, limit = itemsPerRow,
enableTotalRecordCount = false, enableTotalRecordCount = false,
) )
GetItemsRequestHandler GetItemsRequestHandler.execute(api, request).toBaseItems(api, true)
.execute(api, unwatchedTopRatedRequest) }
.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) { if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) {

View file

@ -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)
}
}

View file

@ -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])
}
}

View file

@ -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) }
}
}

View file

@ -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)
}
}

View file

@ -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,
)

View file

@ -41,6 +41,8 @@ preferenceKtx = "1.2.1"
tvprovider = "1.1.0" tvprovider = "1.1.0"
workRuntimeKtx = "2.11.1" workRuntimeKtx = "2.11.1"
paletteKtx = "1.0.0" paletteKtx = "1.0.0"
kotlinxCoroutinesTest = "1.7.3"
coreTesting = "2.2.0"
openapi-generator = "7.19.0" openapi-generator = "7.19.0"
[libraries] [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-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" } 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-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-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } 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-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" }
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }