diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 37729e19..5a1dbd68 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -223,6 +223,7 @@ dependencies { implementation(libs.androidx.tv.foundation) implementation(libs.androidx.tv.material) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.datastore) implementation(libs.protobuf.kotlin.lite) @@ -299,5 +300,7 @@ dependencies { testImplementation(libs.mockk.android) testImplementation(libs.mockk.agent) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.core.testing) testImplementation(libs.robolectric) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 567f8117..dcc25af0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -52,6 +52,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.ServerEventListener import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.services.SuggestionsSchedulerService import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient @@ -119,6 +120,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var tvProviderSchedulerService: TvProviderSchedulerService + @Inject + lateinit var suggestionsSchedulerService: SuggestionsSchedulerService + // Note: unused but injected to ensure it is created @Inject lateinit var serverEventListener: ServerEventListener diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt index 4be082a5..cc9f5779 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt @@ -4,6 +4,9 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import com.github.damontecres.wholphin.ui.nav.Destination import dagger.hilt.android.scopes.ActivityRetainedScoped +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -16,6 +19,7 @@ class PlaybackLifecycleObserver private val navigationManager: NavigationManager, private val playerFactory: PlayerFactory, private val themeSongPlayer: ThemeSongPlayer, + private val suggestionsCache: SuggestionsCache, ) : DefaultLifecycleObserver { private var wasPlaying: Boolean? = null @@ -48,5 +52,6 @@ class PlaybackLifecycleObserver override fun onStop(owner: LifecycleOwner) { themeSongPlayer.stop() + CoroutineScope(Dispatchers.Main).launch { suggestionsCache.save() } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt new file mode 100644 index 00000000..a38c713d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt @@ -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, + ) : 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 { + 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, + itemKind: BaseItemKind, + ): List { + 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) } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt new file mode 100644 index 00000000..f131b13b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt @@ -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, +) + +@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 = _cacheVersion.asStateFlow() + + private val memoryCache: MutableMap = + LinkedHashMap(MAX_MEMORY_CACHE_SIZE, 0.75f, true) + + @Volatile + private var diskCacheLoadedUserId: UUID? = null + private val dirtyKeys: MutableSet = 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? { + 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(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(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, + ) { + 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 + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt new file mode 100644 index 00000000..1b2132fc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt @@ -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() + .setConstraints(constraints) + .setInputData(inputData) + .build(), + ) + } + + Timber.i("Scheduling periodic SuggestionsWorker") + workManager.enqueueUniquePeriodicWork( + uniqueWorkName = SuggestionsWorker.WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, + request = + PeriodicWorkRequestBuilder( + repeatInterval = 12.hours.toJavaDuration(), + ).setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 15.minutes.toJavaDuration(), + ).setInputData(inputData) + .build(), + ) + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt new file mode 100644 index 00000000..70943f83 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -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, + 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 = + 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? = null, + extraFields: List = emptyList(), + ): List { + 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 + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index e6ae832a..f207562c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services.hilt import android.content.Context import androidx.datastore.core.DataStore +import androidx.work.WorkManager import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository @@ -176,6 +177,12 @@ object AppModule { @IoCoroutineScope fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Provides + @Singleton + fun workManager( + @ApplicationContext context: Context, + ): WorkManager = WorkManager.getInstance(context) + @Provides @Singleton fun seerrApi( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt index 05ce1d2a..ab0e04bc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt @@ -29,9 +29,9 @@ class TvProviderSchedulerService constructor( @param:ActivityContext private val context: Context, private val serverRepository: ServerRepository, + private val workManager: WorkManager, ) { private val activity = (context as AppCompatActivity) - private val workManager = WorkManager.getInstance(context) private val supportsTvProvider = // TODO <=25 has limited support diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index 1272faf5..866efbf3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -15,6 +15,8 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SuggestionService +import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.setValueOnMain @@ -22,7 +24,6 @@ import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler -import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import dagger.assisted.Assisted @@ -42,7 +43,6 @@ import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest -import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import timber.log.Timber import java.util.UUID @@ -54,6 +54,7 @@ class RecommendedMovieViewModel private val api: ApiClient, private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, + private val suggestionService: SuggestionService, @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, @@ -115,7 +116,7 @@ class RecommendedMovieViewModel } update(R.string.recently_released) { - val recentlyReleasedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -128,13 +129,11 @@ class RecommendedMovieViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - GetItemsRequestHandler - .execute(api, recentlyReleasedRequest) - .toBaseItems(api, false) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) } update(R.string.recently_added) { - val recentlyAddedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -147,27 +146,11 @@ class RecommendedMovieViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - GetItemsRequestHandler - .execute(api, recentlyAddedRequest) - .toBaseItems(api, false) - } - - update(R.string.suggestions) { - val suggestionsRequest = - GetSuggestionsRequest( - userId = serverRepository.currentUser.value?.id, - type = listOf(BaseItemKind.MOVIE), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetSuggestionsRequestHandler - .execute(api, suggestionsRequest) - .toBaseItems(api, false) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) } update(R.string.top_unwatched) { - val unwatchedTopRatedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -181,9 +164,48 @@ class RecommendedMovieViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - GetItemsRequestHandler - .execute(api, unwatchedTopRatedRequest) - .toBaseItems(api, false) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) + } + + viewModelScope.launch(Dispatchers.IO) { + try { + suggestionService + .getSuggestionsFlow(parentId, BaseItemKind.MOVIE) + .collect { resource -> + val state = + when (resource) { + is SuggestionsResource.Loading -> { + HomeRowLoadingState.Loading( + context.getString(R.string.suggestions), + ) + } + + is SuggestionsResource.Success -> { + HomeRowLoadingState.Success( + context.getString(R.string.suggestions), + resource.items, + ) + } + + is SuggestionsResource.Empty -> { + HomeRowLoadingState.Success( + context.getString(R.string.suggestions), + emptyList(), + ) + } + } + update(R.string.suggestions, state) + } + } catch (ex: Exception) { + Timber.e(ex, "Failed to fetch suggestions") + update( + R.string.suggestions, + HomeRowLoadingState.Error( + title = context.getString(R.string.suggestions), + exception = ex, + ), + ) + } } if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index fa4071b6..2decd21a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -15,6 +15,8 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SuggestionService +import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.setValueOnMain @@ -23,7 +25,6 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetNextUpRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler -import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import dagger.assisted.Assisted @@ -45,7 +46,6 @@ import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest -import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import timber.log.Timber import java.util.UUID @@ -58,6 +58,7 @@ class RecommendedTvShowViewModel private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, private val lastestNextUpService: LatestNextUpService, + private val suggestionService: SuggestionService, @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, @@ -86,7 +87,7 @@ class RecommendedTvShowViewModel val userId = serverRepository.currentUser.value?.id try { val resumeItemsDeferred = - viewModelScope.async(Dispatchers.IO) { + async(Dispatchers.IO) { val resumeItemsRequest = GetResumeItemsRequest( userId = userId, @@ -104,7 +105,7 @@ class RecommendedTvShowViewModel } val nextUpItemsDeferred = - viewModelScope.async(Dispatchers.IO) { + async(Dispatchers.IO) { val nextUpRequest = GetNextUpRequest( userId = userId, @@ -116,19 +117,16 @@ class RecommendedTvShowViewModel enableUserData = true, enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp, ) - GetNextUpRequestHandler .execute(api, nextUpRequest) .toBaseItems(api, true) } + val resumeItems = resumeItemsDeferred.await() val nextUpItems = nextUpItemsDeferred.await() + if (combineNextUp) { - val combined = - lastestNextUpService.buildCombined( - resumeItems, - nextUpItems, - ) + val combined = lastestNextUpService.buildCombined(resumeItems, nextUpItems) update( R.string.continue_watching, HomeRowLoadingState.Success( @@ -138,10 +136,7 @@ class RecommendedTvShowViewModel ) update( R.string.next_up, - HomeRowLoadingState.Success( - context.getString(R.string.next_up), - listOf(), - ), + HomeRowLoadingState.Success(context.getString(R.string.next_up), listOf()), ) } else { update( @@ -153,10 +148,7 @@ class RecommendedTvShowViewModel ) update( R.string.next_up, - HomeRowLoadingState.Success( - context.getString(R.string.next_up), - nextUpItems, - ), + HomeRowLoadingState.Success(context.getString(R.string.next_up), nextUpItems), ) } @@ -171,7 +163,7 @@ class RecommendedTvShowViewModel } update(R.string.recently_released) { - val recentlyReleasedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -184,14 +176,11 @@ class RecommendedTvShowViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - - GetItemsRequestHandler - .execute(api, recentlyReleasedRequest) - .toBaseItems(api, true) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) } update(R.string.recently_added) { - val recentlyAddedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -204,29 +193,11 @@ class RecommendedTvShowViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - - GetItemsRequestHandler - .execute(api, recentlyAddedRequest) - .toBaseItems(api, true) - } - - update(R.string.suggestions) { - val suggestionsRequest = - GetSuggestionsRequest( - userId = serverRepository.currentUser.value?.id, - type = listOf(BaseItemKind.SERIES), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - - GetSuggestionsRequestHandler - .execute(api, suggestionsRequest) - .toBaseItems(api, true) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) } update(R.string.top_unwatched) { - val unwatchedTopRatedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -240,9 +211,48 @@ class RecommendedTvShowViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - GetItemsRequestHandler - .execute(api, unwatchedTopRatedRequest) - .toBaseItems(api, true) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) + } + + viewModelScope.launch(Dispatchers.IO) { + try { + suggestionService + .getSuggestionsFlow(parentId, BaseItemKind.SERIES) + .collect { resource -> + val state = + when (resource) { + is SuggestionsResource.Loading -> { + HomeRowLoadingState.Loading( + context.getString(R.string.suggestions), + ) + } + + is SuggestionsResource.Success -> { + HomeRowLoadingState.Success( + context.getString(R.string.suggestions), + resource.items, + ) + } + + is SuggestionsResource.Empty -> { + HomeRowLoadingState.Success( + context.getString(R.string.suggestions), + emptyList(), + ) + } + } + update(R.string.suggestions, state) + } + } catch (ex: Exception) { + Timber.e(ex, "Failed to fetch suggestions") + update( + R.string.suggestions, + HomeRowLoadingState.Error( + title = context.getString(R.string.suggestions), + exception = ex, + ), + ) + } } if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt new file mode 100644 index 00000000..2f858ab1 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt @@ -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(relaxed = true) + private val mockServerRepository = mockk() + private val mockCache = mockk() + private val mockWorkManager = mockk() + + @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): Response = + 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 { every { this@mockk.state } returns state } + + @Test + fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() = + runTest { + val currentUser = MutableLiveData(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(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(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(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(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(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(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(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) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt new file mode 100644 index 00000000..709509c6 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt @@ -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(relaxed = true) + every { mockContext.cacheDir } returns tempDir + return SuggestionsCache(mockContext) + } + + private fun memoryCacheOf(cache: SuggestionsCache): MutableMap { + val field = SuggestionsCache::class.java.getDeclaredField("memoryCache") + field.isAccessible = true + @Suppress("UNCHECKED_CAST") + return field.get(cache) as MutableMap + } + + @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() + 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]) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt new file mode 100644 index 00000000..0df0cf38 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt @@ -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() + private val mockActivity = mockk(relaxed = true) + private val mockServerRepository = mockk(relaxed = true) + private val mockCache = mockk(relaxed = true) + private val mockWorkManager = mockk(relaxed = true) + private val lifecycleRegistry = LifecycleRegistry(mockk(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) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt new file mode 100644 index 00000000..4e388f21 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -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(relaxed = true) + private val mockServerRepository = mockk(relaxed = true) + private val mockPreferences = mockk>(relaxed = true) + private val mockApi = mockk(relaxed = true) + private val mockCache = mockk(relaxed = true) + private val mockUserViewsApi = mockk(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(relaxed = true) { + every { homePagePreferences } returns mockk { every { maxItemsPerRow } returns 25 } + } + + private fun mockQueryResult(items: List = emptyList()) = + mockk>(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() + 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(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) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt new file mode 100644 index 00000000..5027aee8 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt @@ -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, + private val random: List, + private val fresh: List, + 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> { + 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(), + listOf(movie1, movie2), + listOf(movie3), + 3, + "empty contextual - still combines others", + ), + arrayOf( + emptyList(), + emptyList(), + emptyList(), + 0, + "all empty - returns empty", + ), + ) + } + } +} + +// Helper functions to create test data + +private fun movie( + id: UUID = UUID.randomUUID(), + name: String = "Test Movie", + genres: List? = 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? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.EPISODE, + name = name, + seriesId = seriesId, + genreItems = genres, + ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 92e42737..b2a32580 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,8 @@ preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.1" paletteKtx = "1.0.0" +kotlinxCoroutinesTest = "1.7.3" +coreTesting = "2.2.0" openapi-generator = "7.19.0" [libraries] @@ -67,6 +69,7 @@ androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-com androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" } androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } @@ -124,6 +127,8 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }