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

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