From 4eabaa9a525d0be84bafe8343f2ab4607b131e06 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:04:21 -0400 Subject: [PATCH] Add option to remove item from Continue Watching and Next Up rows (#1140) ## Description Adds context menu options for "Remove from continue watching" and "Remove series from next up" ### Remove from continue watching This marks the item as unplayed so it will not be shown on the Continue Watching row anymore. It may still show on a Next Up or combined row though. ### Remove series from next up This first does the same as "Remove from continue watching" above. But it also stores the series' ID in your display preferences as removed. Wholphin will filter out this series from appearing in the Next Up row until you watch _any_ episode in the series in the future. This only works for Wholphin clients! There's a dialog to view which series have been removed and to re-add them manually. Settings->Customize Home->Settings->View removed next up ### Related issues Closes #906 ### Testing Mostly emulator testing ## Screenshots ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 4 + .../wholphin/data/model/BaseItem.kt | 6 +- .../services/DisplayPreferencesService.kt | 59 ++++ .../wholphin/services/FavoriteWatchManager.kt | 4 + .../wholphin/services/HomeSettingsService.kt | 64 ++--- .../wholphin/services/LatestNextUpService.kt | 143 +++++++++- .../wholphin/services/LatestNextUpWorker.kt | 139 ++++++++++ .../wholphin/ui/detail/DetailUtils.kt | 23 ++ .../damontecres/wholphin/ui/main/HomePage.kt | 35 ++- .../wholphin/ui/main/HomeViewModel.kt | 16 ++ .../ui/main/settings/HomeSettingsGlobal.kt | 15 + .../ui/main/settings/HomeSettingsPage.kt | 14 + .../ui/main/settings/RemovedNextUpContent.kt | 259 ++++++++++++++++++ .../damontecres/wholphin/util/LoadingState.kt | 2 + .../wholphin/util/LocalDateSerializer.kt | 18 ++ app/src/main/res/values/strings.xml | 3 + .../services/LatestNextUpServiceTests.kt | 187 +++++++++++++ .../damontecres/wholphin/test/NextUpTest.kt | 21 +- .../wholphin/test/TestHomeRowSamples.kt | 1 + 19 files changed, 964 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/DisplayPreferencesService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt 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 44b0bcb9..5984a046 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedInvalidationService import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.LatestNextUpSchedulerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.RefreshRateService @@ -115,6 +116,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var suggestionsSchedulerService: SuggestionsSchedulerService + @Inject + lateinit var latestNextUpSchedulerService: LatestNextUpSchedulerService + @Inject lateinit var backdropService: BackdropService diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 1b9523cc..efb21849 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -79,8 +79,7 @@ data class BaseItem( val canDelete: Boolean get() = data.canDelete == true - @Transient - val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } + val aspectRatio: Float? get() = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } val indexNumber get() = data.indexNumber @@ -92,8 +91,7 @@ data class BaseItem( val favorite get() = data.userData?.isFavorite ?: false - @Transient - val timeRemainingOrRuntime: Duration? = data.timeRemaining ?: data.runTimeTicks?.ticks + val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks /** * Contains pre computed UI elements that would be expensive to create on the main thread diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DisplayPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DisplayPreferencesService.kt new file mode 100644 index 00000000..d5691fe6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DisplayPreferencesService.kt @@ -0,0 +1,59 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.github.damontecres.wholphin.BuildConfig +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi +import org.jellyfin.sdk.model.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DisplayPreferencesService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + ) { + private val mutex = Mutex() + + suspend fun getDisplayPreferences( + userId: UUID, + displayPreferencesId: String = DEFAULT_DISPLAY_PREF_ID, + client: String = DEFAULT_CLIENT, + ) = api.displayPreferencesApi + .getDisplayPreferences( + userId = userId, + displayPreferencesId = displayPreferencesId, + client = client, + ).content + + suspend fun updateDisplayPreferences( + userId: UUID, + displayPreferencesId: String = DEFAULT_DISPLAY_PREF_ID, + client: String = DEFAULT_CLIENT, + block: MutableMap.() -> Unit, + ) { + mutex.withLock { + val current = getDisplayPreferences(userId, DEFAULT_DISPLAY_PREF_ID) + val customPrefs = + current.customPrefs.toMutableMap().apply { + block.invoke(this) + } + api.displayPreferencesApi.updateDisplayPreferences( + displayPreferencesId = displayPreferencesId, + userId = userId, + client = client, + data = current.copy(customPrefs = customPrefs), + ) + } + } + + companion object { + const val DEFAULT_DISPLAY_PREF_ID = "default" + val DEFAULT_CLIENT = if (BuildConfig.DEBUG) "Wholphin (Debug)" else "Wholphin" + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt index cdaa0db4..93b3ebe4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/FavoriteWatchManager.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.services +import com.github.damontecres.wholphin.data.model.BaseItem import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi @@ -39,4 +40,7 @@ class FavoriteWatchManager } else { api.userLibraryApi.unmarkFavoriteItem(itemId).content } + + suspend fun removeContinueWatching(item: BaseItem) { + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index cbad6d32..784440aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -42,7 +42,6 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi @@ -81,6 +80,7 @@ class HomeSettingsService private val latestNextUpService: LatestNextUpService, private val imageUrlService: ImageUrlService, private val suggestionService: SuggestionService, + private val displayPreferencesService: DisplayPreferencesService, ) { @OptIn(ExperimentalSerializationApi::class) val jsonParser = @@ -103,19 +103,11 @@ class HomeSettingsService suspend fun saveToServer( userId: UUID, settings: HomePageSettings, - displayPreferencesId: String = DISPLAY_PREF_ID, + displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID, ) { - val current = getDisplayPreferences(userId, DISPLAY_PREF_ID) - val customPrefs = - current.customPrefs.toMutableMap().apply { - put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings)) - } - api.displayPreferencesApi.updateDisplayPreferences( - displayPreferencesId = displayPreferencesId, - userId = userId, - client = context.getString(R.string.app_name), - data = current.copy(customPrefs = customPrefs), - ) + displayPreferencesService.updateDisplayPreferences(userId, displayPreferencesId) { + put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings)) + } } /** @@ -127,24 +119,15 @@ class HomeSettingsService */ suspend fun loadFromServer( userId: UUID, - displayPreferencesId: String = DISPLAY_PREF_ID, - ): HomePageSettings? { - val current = getDisplayPreferences(userId, displayPreferencesId) - return current.customPrefs[CUSTOM_PREF_ID]?.let { - val jsonElement = jsonParser.parseToJsonElement(it) - decode(jsonElement) - } - } - - private suspend fun getDisplayPreferences( - userId: UUID, - displayPreferencesId: String, - ) = api.displayPreferencesApi - .getDisplayPreferences( - userId = userId, - displayPreferencesId = displayPreferencesId, - client = context.getString(R.string.app_name), - ).content + displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID, + ): HomePageSettings? = + displayPreferencesService + .getDisplayPreferences(userId, displayPreferencesId) + .customPrefs[CUSTOM_PREF_ID] + ?.let { + val jsonElement = jsonParser.parseToJsonElement(it) + decode(jsonElement) + } /** * Computes the filename for locally saved [HomePageSettings] @@ -334,12 +317,12 @@ class HomeSettingsService */ suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? { val customPrefs = - api.displayPreferencesApi + displayPreferencesService .getDisplayPreferences( displayPreferencesId = "usersettings", userId = userId, client = "emby", - ).content.customPrefs + ).customPrefs val userDto by api.userApi.getUserById(userId) val config = userDto.configuration ?: DefaultUserConfiguration val libraries = @@ -604,6 +587,7 @@ class HomeSettingsService title = context.getString(R.string.continue_watching), items = resume, viewOptions = row.viewOptions, + rowType = row, ) } @@ -622,6 +606,7 @@ class HomeSettingsService title = context.getString(R.string.next_up), items = nextUp, viewOptions = row.viewOptions, + rowType = row, ) } @@ -651,6 +636,7 @@ class HomeSettingsService nextUp, ), viewOptions = row.viewOptions, + rowType = row, ) } @@ -710,6 +696,7 @@ class HomeSettingsService title, genres, viewOptions = row.viewOptions, + rowType = row, ) } @@ -788,6 +775,7 @@ class HomeSettingsService title, it, row.viewOptions, + rowType = row, ) } latest @@ -820,6 +808,7 @@ class HomeSettingsService title, it, row.viewOptions, + rowType = row, ) } } @@ -848,6 +837,7 @@ class HomeSettingsService name ?: context.getString(R.string.collection), it, row.viewOptions, + rowType = row, ) } } @@ -875,6 +865,7 @@ class HomeSettingsService row.name, it, row.viewOptions, + rowType = row, ) } } @@ -925,6 +916,7 @@ class HomeSettingsService title, it, row.viewOptions, + rowType = row, ) } } @@ -949,6 +941,7 @@ class HomeSettingsService context.getString(R.string.active_recordings), it, row.viewOptions, + rowType = row, ) } } @@ -973,6 +966,7 @@ class HomeSettingsService context.getString(R.string.live_tv), it, row.viewOptions, + rowType = row, ) } } @@ -990,6 +984,7 @@ class HomeSettingsService context.getString(R.string.channels), it, row.viewOptions, + rowType = row, ) } } @@ -1012,12 +1007,14 @@ class HomeSettingsService title, suggestions.items, row.viewOptions, + rowType = row, ) } else if (suggestions is SuggestionsResource.Empty) { Success( title, listOf(), row.viewOptions, + rowType = row, ) } else { Error( @@ -1029,7 +1026,6 @@ class HomeSettingsService } companion object { - const val DISPLAY_PREF_ID = "default" const val CUSTOM_PREF_ID = "home_settings" } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index ce945b69..01bdba3f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -1,22 +1,32 @@ +@file:UseSerializers( + UUIDSerializer::class, + LocalDateTimeSerializer::class, +) + package com.github.damontecres.wholphin.services -import android.content.Context import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.util.LocalDateTimeSerializer import com.github.damontecres.wholphin.util.supportItemKinds -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.json.Json import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest +import org.jellyfin.sdk.model.serializer.UUIDSerializer import timber.log.Timber import java.time.LocalDateTime import java.util.UUID @@ -31,9 +41,10 @@ import kotlin.time.Duration.Companion.milliseconds class LatestNextUpService @Inject constructor( - @param:ApplicationContext private val context: Context, private val api: ApiClient, private val datePlayedService: DatePlayedService, + private val displayPreferencesService: DisplayPreferencesService, + private val favoriteWatchManager: FavoriteWatchManager, ) { /** * Get resume (continue watching) items for a user @@ -80,6 +91,7 @@ class LatestNextUpService maxDays: Int, useSeriesForPrimary: Boolean = true, ): List { + val removedSeries = getRemovedFromNextUp(userId) val nextUpDateCutoff = maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) } val request = @@ -100,6 +112,23 @@ class LatestNextUpService .content .items .map { BaseItem.from(it, api, useSeriesForPrimary) } + .filter { + val seriesId = it.data.seriesId + if (seriesId != null && seriesId in removedSeries) { + // User has previously removed the series + val lastPlayedDate = it.data.userData?.lastPlayedDate + if (lastPlayedDate != null) { + // If item played it after it was removed, should include it + lastPlayedDate > removedSeries[seriesId] + } else { + // If unknown last played, filter out + false + } + } else { + true + } + } + return nextUp } @@ -140,4 +169,112 @@ class LatestNextUpService Timber.v("buildCombined took %s", duration) return@withContext result } + + /** + * Remove a series from next up + */ + suspend fun removeFromNextUp( + userId: UUID, + episode: BaseItem, + ) { + favoriteWatchManager.setWatched(episode.id, false) + episode.data.seriesId?.let { seriesId -> + displayPreferencesService.updateDisplayPreferences(userId) { + val removedIds = + get(REMOVED_KEY) + ?.let { + Json.decodeFromString(it).value + }.orEmpty() + .toMutableMap() + removedIds[seriesId] = LocalDateTime.now() + put( + REMOVED_KEY, + Json.encodeToString(RemovedSeriesIds(removedIds)), + ) + } + } + } + + /** + * Get when series were removed from next up + */ + suspend fun getRemovedFromNextUp(userId: UUID): Map = + displayPreferencesService + .getDisplayPreferences(userId) + .customPrefs[REMOVED_KEY] + ?.let { + Json.decodeFromString(it).value + }.orEmpty() + + suspend fun allowSeriesRemovedFromNextUp( + userId: UUID, + seriesId: UUID, + ) { + displayPreferencesService.updateDisplayPreferences(userId) { + val ids = + get(REMOVED_KEY) + ?.let { + Json.decodeFromString(it).value + }.orEmpty() + .toMutableMap() + ids.remove(seriesId) + put( + REMOVED_KEY, + Json.encodeToString(RemovedSeriesIds(ids)), + ) + } + } + + /** + * Check if user has watched a series since removing it + */ + suspend fun updateRemovedFromNextUp(userId: UUID) { + val removed = getRemovedFromNextUp(userId) + val newRemoved = removed.toMutableMap() + var changed = false + removed.forEach { (seriesId, timestamp) -> + val item = + api.itemsApi + .getItems( + userId = userId, + parentId = seriesId, + recursive = true, + includeItemTypes = listOf(BaseItemKind.EPISODE), + sortBy = listOf(ItemSortBy.DATE_PLAYED), + sortOrder = listOf(SortOrder.DESCENDING), + limit = 1, + ).content.items + .firstOrNull() + if (item != null) { + val lastPlayed = item.userData?.lastPlayedDate + if (lastPlayed != null && lastPlayed > timestamp) { + Timber.v("Updating removed next up for series %s", seriesId) + newRemoved.remove(seriesId) + changed = true + } + } else { + // Series doesn't exist anymore + Timber.v("Updating removed next up for missing series %s", seriesId) + newRemoved.remove(seriesId) + changed = true + } + } + if (changed) { + displayPreferencesService.updateDisplayPreferences(userId) { + put( + REMOVED_KEY, + Json.encodeToString(RemovedSeriesIds(newRemoved)), + ) + } + } + } + + companion object { + const val REMOVED_KEY = "removeNextUp" + } } + +@Serializable +data class RemovedSeriesIds( + val value: Map, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt new file mode 100644 index 00000000..328346f3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt @@ -0,0 +1,139 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.hilt.work.HiltWorker +import androidx.lifecycle.lifecycleScope +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.util.ExceptionHandler +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +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 org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +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 + +@HiltWorker +class LatestNextUpWorker + @AssistedInject + constructor( + @Assisted private val context: Context, + @Assisted workerParams: WorkerParameters, + private val serverRepository: ServerRepository, + private val api: ApiClient, + private val latestNextUpService: LatestNextUpService, + ) : 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() + + try { + if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { + // Not active + 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() + } + } + latestNextUpService.updateRemovedFromNextUp(userId) + return Result.success() + } catch (ex: Exception) { + Timber.e(ex, "Error during updateRemovedFromNextUp") + return Result.retry() + } + } + + companion object { + const val WORK_NAME = "com.github.damontecres.wholphin.services.LatestNextUpWorker" + const val PARAM_USER_ID = "userId" + const val PARAM_SERVER_ID = "serverId" + } + } + +@ActivityScoped +class LatestNextUpSchedulerService + @Inject + constructor( + @param:ActivityContext private val context: Context, + private val serverRepository: ServerRepository, + 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 periodicWorkRequestBuilder = + PeriodicWorkRequestBuilder( + repeatInterval = 4.hours.toJavaDuration(), + ).setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 15.minutes.toJavaDuration(), + ).setConstraints(constraints) + .setInputData( + workDataOf( + LatestNextUpWorker.PARAM_USER_ID to userId.toString(), + LatestNextUpWorker.PARAM_SERVER_ID to serverId.toString(), + ), + ) + Timber.i("Scheduling periodic LatestNextUpWorker") + + workManager.enqueueUniquePeriodicWork( + uniqueWorkName = LatestNextUpWorker.WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.REPLACE, + request = periodicWorkRequestBuilder.build(), + ) + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index 080c1c4e..74fdaa1e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -31,6 +31,7 @@ data class MoreDialogActions( val onSendMediaInfo: (UUID) -> Unit, val onClickDelete: (BaseItem) -> Unit, val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, + val onClickRemoveFromNextUp: (BaseItem) -> Unit = {}, ) enum class ClearChosenStreams { @@ -270,6 +271,8 @@ fun buildMoreDialogItemsForHome( favorite: Boolean, canDelete: Boolean, actions: MoreDialogActions, + canRemoveContinueWatching: Boolean = false, + canRemoveNextUp: Boolean = false, ): List = buildList { val itemId = item.id @@ -347,6 +350,26 @@ fun buildMoreDialogItemsForHome( }, ) } + if (canRemoveContinueWatching && !watched && playbackPosition > Duration.ZERO) { + add( + DialogItem( + text = R.string.remove_continue_watching, + iconStringRes = R.string.fa_eye, + ) { + actions.onClickWatch.invoke(itemId, false) + }, + ) + } + if (canRemoveNextUp && item.type == BaseItemKind.EPISODE && item.data.seriesId != null) { + add( + DialogItem( + text = R.string.remove_next_up, + iconStringRes = R.string.fa_tag, + ) { + actions.onClickRemoveFromNextUp.invoke(item) + }, + ) + } add( DialogItem( text = if (watched) R.string.mark_unwatched else R.string.mark_watched, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index b9608dcc..ef0fdbad 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -22,7 +22,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -47,6 +46,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.Cards @@ -138,6 +138,12 @@ fun HomePage( remember { { clickedPosition: RowColumn, item: BaseItem -> position = clickedPosition + val row = + (homeRows.getOrNull(clickedPosition.row) as? HomeRowLoadingState.Success) + val canRemoveContinueWatching = + row?.rowType is HomeRowConfig.ContinueWatching || row?.rowType is HomeRowConfig.ContinueWatchingCombined + val canRemoveNextUp = + row?.rowType is HomeRowConfig.NextUp || row?.rowType is HomeRowConfig.ContinueWatchingCombined val dialogItems = buildMoreDialogItemsForHome( context = context, @@ -147,6 +153,8 @@ fun HomePage( watched = item.played, favorite = item.favorite, canDelete = viewModel.canDelete(item, preferences.appPreferences), + canRemoveNextUp = canRemoveNextUp, + canRemoveContinueWatching = canRemoveContinueWatching, actions = MoreDialogActions( navigateTo = viewModel.navigationManager::navigateTo, @@ -164,6 +172,7 @@ fun HomePage( onClickDelete = { showDeleteDialog = RowColumnItem(position, item) }, + onClickRemoveFromNextUp = viewModel::removeFromNextUp, ), ) dialog = @@ -260,7 +269,9 @@ fun HomePageContent( ) { val focusedItem = remember(homeRows, position) { - (homeRows.getOrNull(position.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(position.column) + (homeRows.getOrNull(position.row) as? HomeRowLoadingState.Success)?.items?.getOrNull( + position.column, + ) } val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } } @@ -354,7 +365,13 @@ fun HomePageContent( onClickItem = remember(rowIndex, onClickItem) { { index, item -> - onClickItem.invoke(RowColumn(rowIndex, index), item) + onClickItem.invoke( + RowColumn( + rowIndex, + index, + ), + item, + ) } }, onLongClickItem = @@ -378,7 +395,12 @@ fun HomePageContent( remember(rowIndex, index) { { isFocused: Boolean -> if (isFocused) { - currentOnFocusPosition(RowColumn(rowIndex, index)) + currentOnFocusPosition( + RowColumn( + rowIndex, + index, + ), + ) } } } @@ -387,7 +409,10 @@ fun HomePageContent( { event: androidx.compose.ui.input.key.KeyEvent -> if (isPlayKeyUp(event) && item?.type?.playable == true) { Timber.v("Clicked play on ${item.id}") - currentOnClickPlay(currentPosition, item) + currentOnClickPlay( + currentPosition, + item, + ) true } else { false diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index f53f80f7..6d1517c2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomeSettingsService +import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavDrawerService @@ -39,6 +40,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext +import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -58,6 +60,7 @@ class HomeViewModel private val backdropService: BackdropService, private val userPreferencesService: UserPreferencesService, private val mediaManagementService: MediaManagementService, + private val latestNextUpService: LatestNextUpService, ) : ViewModel() { private val _state = MutableStateFlow(HomeState.EMPTY) val state: StateFlow = _state @@ -231,6 +234,19 @@ class HomeViewModel item: BaseItem, appPreferences: AppPreferences, ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + fun removeFromNextUp(item: BaseItem) { + if (item.type == BaseItemKind.EPISODE) { + viewModelScope.launchDefault { + serverRepository.currentUser.value?.id?.let { userId -> + latestNextUpService.removeFromNextUp(userId, item) + init() + } + } + } else { + Timber.w("Item is not an episode %s", item.id) + } + } } data class HomeState( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt index e4b61881..35c17a7e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt @@ -38,6 +38,7 @@ fun HomeSettingsGlobal( onClickLoad: () -> Unit, onClickLoadWeb: () -> Unit, onClickReset: () -> Unit, + onClickViewNextUp: () -> Unit, modifier: Modifier = Modifier, ) { val firstFocus: FocusRequester = remember { FocusRequester() } @@ -123,6 +124,20 @@ fun HomeSettingsGlobal( ) } item { HorizontalDivider() } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.view_removed_next_up), + leadingContent = { + Text( + text = stringResource(R.string.fa_eye), + fontFamily = FontAwesome, + ) + }, + onClick = onClickViewNextUp, + modifier = Modifier, + ) + } item { HomeSettingsListItem( selected = false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index a2a1ac63..3ec26f92 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog @@ -61,6 +62,7 @@ fun HomeSettingsPage( val backStack = rememberNavBackStack(HomeSettingsDestination.RowList) var showConfirmDialog by remember { mutableStateOf(null) } var searchForDialog by remember { mutableStateOf(null) } + var showRemovedNextUpDialog by remember { mutableStateOf(false) } val state by viewModel.state.collectAsState() var position by rememberPosition(0, 0) @@ -284,6 +286,9 @@ fun HomeSettingsPage( addRow(false) { viewModel.resetToDefault() } } }, + onClickViewNextUp = { + showRemovedNextUpDialog = true + }, modifier = destModifier, ) } @@ -340,6 +345,15 @@ fun HomeSettingsPage( }, ) } + if (showRemovedNextUpDialog) { + BasicDialog( + onDismissRequest = { showRemovedNextUpDialog = false }, + ) { + RemovedNextUpContent( + modifier = Modifier.padding(16.dp), + ) + } + } } data class ShowConfirm( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt new file mode 100644 index 00000000..605f76d5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/RemovedNextUpContent.kt @@ -0,0 +1,259 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.Icon +import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.formatDateTime +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.model.api.ImageType +import timber.log.Timber +import java.time.LocalDateTime +import javax.inject.Inject + +@HiltViewModel +class RemovedNextUpContentViewModel + @Inject + constructor( + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val latestNextUpService: LatestNextUpService, + private val imageUrlService: ImageUrlService, + ) : ViewModel() { + private val mutex = Mutex() + + private val _state = MutableStateFlow(RemovedNextUpState()) + val state: StateFlow = _state + + init { + viewModelScope.launchDefault { + serverRepository.currentUser.asFlow().collectLatest { user -> + _state.update { RemovedNextUpState() } + if (user == null) { + return@collectLatest + } + try { + val removed = latestNextUpService.getRemovedFromNextUp(user.id) + val series = mutableListOf() + removed.keys.chunked(50).forEach { ids -> + val results = + api.itemsApi + .getItems( + userId = user.id, + ids = ids, + ).toBaseItems(api, false) + results.forEach { + val imageUrl = imageUrlService.getItemImageUrl(it, ImageType.PRIMARY) + series.add(RemovedItem(it, imageUrl, removed[it.id]!!)) + } + } + _state.update { it.copy(loading = DataLoadingState.Success(series)) } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching removed series") + _state.update { it.copy(loading = DataLoadingState.Error(ex)) } + } + } + } + } + + fun remove(item: RemovedItem) { + serverRepository.currentUser.value?.let { user -> + viewModelScope.launchDefault { + mutex.withLock { + _state.update { it.copy(removedEnabled = false) } + try { + latestNextUpService.allowSeriesRemovedFromNextUp(user.id, item.series.id) + val newItems = + (_state.value.loading as? DataLoadingState.Success>) + ?.data + ?.toMutableList() + ?.apply { + removeIf { it.series.id == item.series.id } + } + val loading = + if (newItems != null) { + DataLoadingState.Success(newItems) + } else { + DataLoadingState.Error("Error occurred") + } + _state.update { + it.copy( + loading = loading, + removedEnabled = true, + ) + } + } catch (ex: Exception) { + Timber.e(ex, "Error removing %s from removed next up", item.series.id) + } finally { + _state.update { it.copy(removedEnabled = true) } + } + } + } + } + } + } + +@Stable +data class RemovedItem( + val series: BaseItem, + val imageUrl: String?, + val datetime: LocalDateTime, +) + +data class RemovedNextUpState( + val loading: DataLoadingState> = DataLoadingState.Pending, + val removedEnabled: Boolean = true, +) + +@Composable +fun RemovedNextUpContent( + modifier: Modifier = Modifier, + viewModel: RemovedNextUpContentViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + Column( + modifier = modifier, + ) { + Text( + text = "Removed from next up", + style = MaterialTheme.typography.displaySmall, + ) + when (val s = state.loading) { + DataLoadingState.Pending, + DataLoadingState.Loading, + -> { + LoadingPage(Modifier.fillMaxWidth()) + } + + is DataLoadingState.Error -> { + ErrorMessage(s, Modifier.fillMaxWidth()) + } + + is DataLoadingState.Success> -> { + if (s.data.isEmpty()) { + Text( + text = stringResource(R.string.no_results), + ) + } else { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LazyColumn( + contentPadding = PaddingValues(horizontal = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + ) { + items(s.data, key = { it.series.id }) { item -> + RemovedListItem( + item = item, + removedEnabled = state.removedEnabled, + onClickRemove = + remember { + { viewModel.remove(item) } + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } + } +} + +@Composable +fun RemovedListItem( + item: RemovedItem, + removedEnabled: Boolean, + onClickRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + ListItem( + selected = false, + onClick = {}, + leadingContent = { + AsyncImage( + model = item.imageUrl, + contentDescription = item.series.title, + modifier = Modifier.height(80.dp), + ) + }, + headlineContent = { + Text( + text = item.series.title ?: item.series.id.toString(), + ) + }, + supportingContent = { + Text( + text = formatDateTime(item.datetime), + ) + }, + scale = ListItemDefaults.scale(focusedScale = 1f), + modifier = Modifier.weight(1f), + ) + Button( + onClick = onClickRemove, + enabled = removedEnabled, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "delete", + modifier = Modifier, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index 5da98fec..97308113 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.util import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions /** @@ -64,6 +65,7 @@ sealed interface HomeRowLoadingState { override val title: String, val items: List, val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + val rowType: HomeRowConfig? = null, ) : HomeRowLoadingState data class Error( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt index 89b900f0..b88bcb8a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import java.time.LocalDate +import java.time.LocalDateTime import java.time.format.DateTimeFormatter object LocalDateSerializer : KSerializer { @@ -22,3 +23,20 @@ object LocalDateSerializer : KSerializer { override fun deserialize(decoder: Decoder): LocalDate = decoder.decodeString().let { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) } } + +object LocalDateTimeSerializer : KSerializer { + override val descriptor: SerialDescriptor + get() = SerialDescriptor("LocalDateTime", String.serializer().descriptor) + + override fun serialize( + encoder: Encoder, + value: LocalDateTime, + ) { + encoder.encodeString(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(value)) + } + + override fun deserialize(decoder: Decoder): LocalDateTime = + decoder + .decodeString() + .let { LocalDateTime.parse(it, DateTimeFormatter.ISO_LOCAL_DATE_TIME) } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 46afd62a..154aeeae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -749,6 +749,9 @@ Separate types Prefer showing logos for titles Wholphin updated to %s + Remove from continue watching + Remove series from next up + View series removed from next up View more Discover TV Shows Discover Movies diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt new file mode 100644 index 00000000..4c605df4 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/LatestNextUpServiceTests.kt @@ -0,0 +1,187 @@ +@file:UseSerializers( + UUIDSerializer::class, + DateTimeSerializer::class, +) + +package com.github.damontecres.wholphin.services + +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.json.Json +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.Response +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.operations.TvShowsApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult +import org.jellyfin.sdk.model.api.DisplayPreferencesDto +import org.jellyfin.sdk.model.api.ScrollDirection +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.UserItemDataDto +import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.jellyfin.sdk.model.serializer.DateTimeSerializer +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import java.time.LocalDateTime +import java.util.UUID + +class LatestNextUpServiceTests { + private val testDispatcher = StandardTestDispatcher() + + private val userId = UUID.randomUUID() + private val seriesId1 = UUID.randomUUID() + private val seriesId2 = UUID.randomUUID() + private val seriesId3 = UUID.randomUUID() + + private val mockApi = mockk(relaxed = true) + private val mockTvShowsApi = mockk() + private val mockDatePlayedService = mockk() + private val mockDisplayPreferencesService = mockk() + private val mockFavoriteWatchManager = mockk(relaxed = true) + + private val latestNextUpService = + LatestNextUpService(mockApi, mockDatePlayedService, mockDisplayPreferencesService, mockFavoriteWatchManager) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + every { mockApi.tvShowsApi } returns mockTvShowsApi + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `Test nothing is filtered out`() = + runTest { + coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse + coEvery { mockDisplayPreferencesService.getDisplayPreferences(any(), any(), any()) } returns + buildRemoved() + val result = latestNextUpService.getNextUp(userId, 20, false, false, 0) + Assert.assertEquals(3, result.size) + val seriesIds = result.map { it.data.seriesId } + Assert.assertTrue(seriesIds.containsAll(listOf(seriesId1, seriesId2, seriesId3))) + } + + @Test + fun `Test seriesId1 is filtered out`() = + runTest { + coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse + coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns + buildRemoved(seriesId1 to LocalDateTime.now().minusDays(1)) + val result = latestNextUpService.getNextUp(userId, 20, false, false, 0) + Assert.assertEquals(2, result.size) + val seriesIds = result.map { it.data.seriesId } + Assert.assertTrue(seriesId1 !in seriesIds) + Assert.assertTrue(seriesIds.containsAll(listOf(seriesId2, seriesId3))) + } + + @Test + fun `Test seriesId2 is filtered out`() = + runTest { + coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse + coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns + buildRemoved(seriesId2 to LocalDateTime.now().minusDays(1)) + val result = latestNextUpService.getNextUp(userId, 20, false, false, 0) + Assert.assertEquals(2, result.size) + val seriesIds = result.map { it.data.seriesId } + Assert.assertTrue(seriesId2 !in seriesIds) + Assert.assertTrue(seriesIds.containsAll(listOf(seriesId1, seriesId3))) + } + + @Test + fun `Test seriesId1 and seriesId2 are filtered out`() = + runTest { + coEvery { mockTvShowsApi.getNextUp(any() as GetNextUpRequest) } returns mockResponse + coEvery { mockDisplayPreferencesService.getDisplayPreferences(any()) } returns + buildRemoved( + seriesId1 to LocalDateTime.now().minusDays(1), + seriesId2 to LocalDateTime.now().minusDays(1), + ) + val result = latestNextUpService.getNextUp(userId, 20, false, false, 0) + Assert.assertEquals(1, result.size) + val seriesIds = result.map { it.data.seriesId } + Assert.assertTrue(seriesId1 !in seriesIds) + Assert.assertTrue(seriesId2 !in seriesIds) + Assert.assertTrue(seriesIds.containsAll(listOf(seriesId3))) + } + + fun buildRemoved(vararg values: Pair): DisplayPreferencesDto = + testDisplayPreferencesDto.copy( + customPrefs = + mutableMapOf().apply { + val str = Json.encodeToString(RemovedSeriesIds(values.toMap())) + put(LatestNextUpService.REMOVED_KEY, str) + }, + ) + + private val testUserItemDataDto = + UserItemDataDto( + playbackPositionTicks = 0L, + playCount = 0, + isFavorite = false, + lastPlayedDate = null, + played = false, + key = "", + itemId = UUID.randomUUID(), + ) + + private val mockResponse: Response = + Response( + content = + BaseItemDtoQueryResult( + items = + listOf( + mockk(relaxed = true) { + every { seriesId } returns seriesId1 + every { userData } returns testUserItemDataDto.copy(lastPlayedDate = LocalDateTime.now().minusDays(7)) + }, + mockk(relaxed = true) { + every { seriesId } returns seriesId2 + every { userData } returns testUserItemDataDto.copy(lastPlayedDate = null) + }, + mockk(relaxed = true) { + every { seriesId } returns seriesId3 + every { userData } returns testUserItemDataDto.copy(lastPlayedDate = LocalDateTime.now().plusDays(7)) + }, + ), + totalRecordCount = 3, + startIndex = 0, + ), + status = 200, + headers = mapOf(), + ) +} + +val testDisplayPreferencesDto = + DisplayPreferencesDto( + id = "default", + viewType = null, + sortBy = null, + indexBy = null, + rememberIndexing = false, + primaryImageHeight = 0, + primaryImageWidth = 0, + customPrefs = mapOf(), + scrollDirection = ScrollDirection.VERTICAL, + showBackdrop = false, + rememberSorting = false, + sortOrder = SortOrder.ASCENDING, + showSidebar = false, + client = null, + ) diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt index 0e9bb92e..c5c2ffee 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt @@ -1,13 +1,15 @@ package com.github.damontecres.wholphin.test -import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.updateHomePagePreferences import com.github.damontecres.wholphin.services.DatePlayedService +import com.github.damontecres.wholphin.services.DisplayPreferencesService +import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.mockQueryResult +import com.github.damontecres.wholphin.services.testDisplayPreferencesDto import io.mockk.CapturingSlot import io.mockk.coEvery import io.mockk.every @@ -33,15 +35,28 @@ class NextUpTest { private val mockTvShowsApi = mockk() private val mockApi = mockk(relaxed = true) - private val mockContext = mockk() private val mockDatePlayedService = mockk() + private val mockDisplayPreferencesService = mockk() + private val mockFavoriteWatchManager = mockk(relaxed = true) private val latestNextUpService = - LatestNextUpService(mockContext, mockApi, mockDatePlayedService) + LatestNextUpService( + mockApi, + mockDatePlayedService, + mockDisplayPreferencesService, + mockFavoriteWatchManager, + ) @Before fun setUp() { every { mockApi.tvShowsApi } returns mockTvShowsApi + coEvery { + mockDisplayPreferencesService.getDisplayPreferences( + any(), + any(), + any(), + ) + } returns testDisplayPreferencesDto } @Test diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt index 23ab54d5..e8c2fed1 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt @@ -131,6 +131,7 @@ class TestHomeRowSamples { latestNextUpService = mockk(), imageUrlService = mockk(), suggestionService = mockk(), + displayPreferencesService = mockk(), ) val str = """{