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 44b0bcb9f..5984a0463 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 cf7461ea9..f89d14f7d 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 000000000..d5691fe65 --- /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 cdaa0db4d..93b3ebe40 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 2abe6a1ea..7a9c3a2df 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 @@ -40,7 +40,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 @@ -78,6 +77,7 @@ class HomeSettingsService private val latestNextUpService: LatestNextUpService, private val imageUrlService: ImageUrlService, private val suggestionService: SuggestionService, + private val displayPreferencesService: DisplayPreferencesService, ) { @OptIn(ExperimentalSerializationApi::class) val jsonParser = @@ -100,19 +100,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)) + } } /** @@ -124,24 +116,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] @@ -331,12 +314,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 = @@ -592,6 +575,7 @@ class HomeSettingsService title = context.getString(R.string.continue_watching), items = resume, viewOptions = row.viewOptions, + rowType = row, ) } @@ -610,6 +594,7 @@ class HomeSettingsService title = context.getString(R.string.next_up), items = nextUp, viewOptions = row.viewOptions, + rowType = row, ) } @@ -639,6 +624,7 @@ class HomeSettingsService nextUp, ), viewOptions = row.viewOptions, + rowType = row, ) } @@ -698,6 +684,7 @@ class HomeSettingsService title, genres, viewOptions = row.viewOptions, + rowType = row, ) } @@ -725,6 +712,7 @@ class HomeSettingsService title, it, row.viewOptions, + rowType = row, ) } latest @@ -757,6 +745,7 @@ class HomeSettingsService title, it, row.viewOptions, + rowType = row, ) } } @@ -785,6 +774,7 @@ class HomeSettingsService name ?: context.getString(R.string.collection), it, row.viewOptions, + rowType = row, ) } } @@ -812,6 +802,7 @@ class HomeSettingsService row.name, it, row.viewOptions, + rowType = row, ) } } @@ -862,6 +853,7 @@ class HomeSettingsService title, it, row.viewOptions, + rowType = row, ) } } @@ -886,6 +878,7 @@ class HomeSettingsService context.getString(R.string.active_recordings), it, row.viewOptions, + rowType = row, ) } } @@ -910,6 +903,7 @@ class HomeSettingsService context.getString(R.string.live_tv), it, row.viewOptions, + rowType = row, ) } } @@ -927,6 +921,7 @@ class HomeSettingsService context.getString(R.string.channels), it, row.viewOptions, + rowType = row, ) } } @@ -949,12 +944,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( @@ -966,7 +963,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 ce945b69f..01bdba3f2 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 000000000..328346f37 --- /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 080c1c4eb..74fdaa1e0 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 9addbb0ef..2f8c42330 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 @@ -137,6 +137,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, @@ -146,6 +152,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, @@ -163,6 +171,7 @@ fun HomePage( onClickDelete = { showDeleteDialog = RowColumnItem(position, item) }, + onClickRemoveFromNextUp = viewModel::removeFromNextUp, ), ) dialog = @@ -259,7 +268,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() } } @@ -353,7 +364,13 @@ fun HomePageContent( onClickItem = remember(rowIndex, onClickItem) { { index, item -> - onClickItem.invoke(RowColumn(rowIndex, index), item) + onClickItem.invoke( + RowColumn( + rowIndex, + index, + ), + item, + ) } }, onLongClickItem = @@ -377,7 +394,12 @@ fun HomePageContent( remember(rowIndex, index) { { isFocused: Boolean -> if (isFocused) { - currentOnFocusPosition(RowColumn(rowIndex, index)) + currentOnFocusPosition( + RowColumn( + rowIndex, + index, + ), + ) } } } @@ -386,7 +408,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 f53f80f76..6d1517c2c 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 e4b618813..35c17a7e8 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 a2a1ac632..3ec26f929 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 000000000..605f76d5c --- /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 5da98fec7..973081136 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 89b900f04..b88bcb8a2 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 c41b5511e..68928570f 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 000000000..4c605df4a --- /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 0e9bb92e8..c5c2ffeef 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 84aa72b64..404e27ca5 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 @@ -129,6 +129,7 @@ class TestHomeRowSamples { latestNextUpService = mockk(), imageUrlService = mockk(), suggestionService = mockk(), + displayPreferencesService = mockk(), ) val str = """{