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
This commit is contained in:
Ray 2026-04-07 16:04:21 -04:00 committed by GitHub
parent ade564d907
commit 4eabaa9a52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 964 additions and 49 deletions

View file

@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService 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.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.RefreshRateService
@ -115,6 +116,9 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
@Inject
lateinit var latestNextUpSchedulerService: LatestNextUpSchedulerService
@Inject @Inject
lateinit var backdropService: BackdropService lateinit var backdropService: BackdropService

View file

@ -79,8 +79,7 @@ data class BaseItem(
val canDelete: Boolean get() = data.canDelete == true val canDelete: Boolean get() = data.canDelete == true
@Transient val aspectRatio: Float? get() = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
val indexNumber get() = data.indexNumber val indexNumber get() = data.indexNumber
@ -92,8 +91,7 @@ data class BaseItem(
val favorite get() = data.userData?.isFavorite ?: false val favorite get() = data.userData?.isFavorite ?: false
@Transient val timeRemainingOrRuntime: Duration? get() = data.timeRemaining ?: data.runTimeTicks?.ticks
val timeRemainingOrRuntime: Duration? = data.timeRemaining ?: data.runTimeTicks?.ticks
/** /**
* Contains pre computed UI elements that would be expensive to create on the main thread * Contains pre computed UI elements that would be expensive to create on the main thread

View file

@ -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<String, String?>.() -> 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"
}
}

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.services 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.ApiClient
import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -39,4 +40,7 @@ class FavoriteWatchManager
} else { } else {
api.userLibraryApi.unmarkFavoriteItem(itemId).content api.userLibraryApi.unmarkFavoriteItem(itemId).content
} }
suspend fun removeContinueWatching(item: BaseItem) {
}
} }

View file

@ -42,7 +42,6 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import org.jellyfin.sdk.api.client.ApiClient 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.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@ -81,6 +80,7 @@ class HomeSettingsService
private val latestNextUpService: LatestNextUpService, private val latestNextUpService: LatestNextUpService,
private val imageUrlService: ImageUrlService, private val imageUrlService: ImageUrlService,
private val suggestionService: SuggestionService, private val suggestionService: SuggestionService,
private val displayPreferencesService: DisplayPreferencesService,
) { ) {
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
val jsonParser = val jsonParser =
@ -103,19 +103,11 @@ class HomeSettingsService
suspend fun saveToServer( suspend fun saveToServer(
userId: UUID, userId: UUID,
settings: HomePageSettings, settings: HomePageSettings,
displayPreferencesId: String = DISPLAY_PREF_ID, displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID,
) { ) {
val current = getDisplayPreferences(userId, DISPLAY_PREF_ID) displayPreferencesService.updateDisplayPreferences(userId, displayPreferencesId) {
val customPrefs =
current.customPrefs.toMutableMap().apply {
put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings)) 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),
)
} }
/** /**
@ -127,24 +119,15 @@ class HomeSettingsService
*/ */
suspend fun loadFromServer( suspend fun loadFromServer(
userId: UUID, userId: UUID,
displayPreferencesId: String = DISPLAY_PREF_ID, displayPreferencesId: String = DisplayPreferencesService.DEFAULT_DISPLAY_PREF_ID,
): HomePageSettings? { ): HomePageSettings? =
val current = getDisplayPreferences(userId, displayPreferencesId) displayPreferencesService
return current.customPrefs[CUSTOM_PREF_ID]?.let { .getDisplayPreferences(userId, displayPreferencesId)
.customPrefs[CUSTOM_PREF_ID]
?.let {
val jsonElement = jsonParser.parseToJsonElement(it) val jsonElement = jsonParser.parseToJsonElement(it)
decode(jsonElement) 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
/** /**
* Computes the filename for locally saved [HomePageSettings] * Computes the filename for locally saved [HomePageSettings]
@ -334,12 +317,12 @@ class HomeSettingsService
*/ */
suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? { suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? {
val customPrefs = val customPrefs =
api.displayPreferencesApi displayPreferencesService
.getDisplayPreferences( .getDisplayPreferences(
displayPreferencesId = "usersettings", displayPreferencesId = "usersettings",
userId = userId, userId = userId,
client = "emby", client = "emby",
).content.customPrefs ).customPrefs
val userDto by api.userApi.getUserById(userId) val userDto by api.userApi.getUserById(userId)
val config = userDto.configuration ?: DefaultUserConfiguration val config = userDto.configuration ?: DefaultUserConfiguration
val libraries = val libraries =
@ -604,6 +587,7 @@ class HomeSettingsService
title = context.getString(R.string.continue_watching), title = context.getString(R.string.continue_watching),
items = resume, items = resume,
viewOptions = row.viewOptions, viewOptions = row.viewOptions,
rowType = row,
) )
} }
@ -622,6 +606,7 @@ class HomeSettingsService
title = context.getString(R.string.next_up), title = context.getString(R.string.next_up),
items = nextUp, items = nextUp,
viewOptions = row.viewOptions, viewOptions = row.viewOptions,
rowType = row,
) )
} }
@ -651,6 +636,7 @@ class HomeSettingsService
nextUp, nextUp,
), ),
viewOptions = row.viewOptions, viewOptions = row.viewOptions,
rowType = row,
) )
} }
@ -710,6 +696,7 @@ class HomeSettingsService
title, title,
genres, genres,
viewOptions = row.viewOptions, viewOptions = row.viewOptions,
rowType = row,
) )
} }
@ -788,6 +775,7 @@ class HomeSettingsService
title, title,
it, it,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} }
latest latest
@ -820,6 +808,7 @@ class HomeSettingsService
title, title,
it, it,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} }
} }
@ -848,6 +837,7 @@ class HomeSettingsService
name ?: context.getString(R.string.collection), name ?: context.getString(R.string.collection),
it, it,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} }
} }
@ -875,6 +865,7 @@ class HomeSettingsService
row.name, row.name,
it, it,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} }
} }
@ -925,6 +916,7 @@ class HomeSettingsService
title, title,
it, it,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} }
} }
@ -949,6 +941,7 @@ class HomeSettingsService
context.getString(R.string.active_recordings), context.getString(R.string.active_recordings),
it, it,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} }
} }
@ -973,6 +966,7 @@ class HomeSettingsService
context.getString(R.string.live_tv), context.getString(R.string.live_tv),
it, it,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} }
} }
@ -990,6 +984,7 @@ class HomeSettingsService
context.getString(R.string.channels), context.getString(R.string.channels),
it, it,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} }
} }
@ -1012,12 +1007,14 @@ class HomeSettingsService
title, title,
suggestions.items, suggestions.items,
row.viewOptions, row.viewOptions,
rowType = row,
) )
} else if (suggestions is SuggestionsResource.Empty) { } else if (suggestions is SuggestionsResource.Empty) {
Success( Success(
title, title,
listOf(), listOf(),
row.viewOptions, row.viewOptions,
rowType = row,
) )
} else { } else {
Error( Error(
@ -1029,7 +1026,6 @@ class HomeSettingsService
} }
companion object { companion object {
const val DISPLAY_PREF_ID = "default"
const val CUSTOM_PREF_ID = "home_settings" const val CUSTOM_PREF_ID = "home_settings"
} }
} }

View file

@ -1,22 +1,32 @@
@file:UseSerializers(
UUIDSerializer::class,
LocalDateTimeSerializer::class,
)
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
import com.github.damontecres.wholphin.util.supportItemKinds import com.github.damontecres.wholphin.util.supportItemKinds
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext 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.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.model.api.BaseItemKind 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.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest
import org.jellyfin.sdk.model.serializer.UUIDSerializer
import timber.log.Timber import timber.log.Timber
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.UUID import java.util.UUID
@ -31,9 +41,10 @@ import kotlin.time.Duration.Companion.milliseconds
class LatestNextUpService class LatestNextUpService
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient, private val api: ApiClient,
private val datePlayedService: DatePlayedService, private val datePlayedService: DatePlayedService,
private val displayPreferencesService: DisplayPreferencesService,
private val favoriteWatchManager: FavoriteWatchManager,
) { ) {
/** /**
* Get resume (continue watching) items for a user * Get resume (continue watching) items for a user
@ -80,6 +91,7 @@ class LatestNextUpService
maxDays: Int, maxDays: Int,
useSeriesForPrimary: Boolean = true, useSeriesForPrimary: Boolean = true,
): List<BaseItem> { ): List<BaseItem> {
val removedSeries = getRemovedFromNextUp(userId)
val nextUpDateCutoff = val nextUpDateCutoff =
maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) } maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) }
val request = val request =
@ -100,6 +112,23 @@ class LatestNextUpService
.content .content
.items .items
.map { BaseItem.from(it, api, useSeriesForPrimary) } .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 return nextUp
} }
@ -140,4 +169,112 @@ class LatestNextUpService
Timber.v("buildCombined took %s", duration) Timber.v("buildCombined took %s", duration)
return@withContext result 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<RemovedSeriesIds>(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<UUID, LocalDateTime> =
displayPreferencesService
.getDisplayPreferences(userId)
.customPrefs[REMOVED_KEY]
?.let {
Json.decodeFromString<RemovedSeriesIds>(it).value
}.orEmpty()
suspend fun allowSeriesRemovedFromNextUp(
userId: UUID,
seriesId: UUID,
) {
displayPreferencesService.updateDisplayPreferences(userId) {
val ids =
get(REMOVED_KEY)
?.let {
Json.decodeFromString<RemovedSeriesIds>(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<UUID, LocalDateTime>,
)

View file

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

View file

@ -31,6 +31,7 @@ data class MoreDialogActions(
val onSendMediaInfo: (UUID) -> Unit, val onSendMediaInfo: (UUID) -> Unit,
val onClickDelete: (BaseItem) -> Unit, val onClickDelete: (BaseItem) -> Unit,
val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) },
val onClickRemoveFromNextUp: (BaseItem) -> Unit = {},
) )
enum class ClearChosenStreams { enum class ClearChosenStreams {
@ -270,6 +271,8 @@ fun buildMoreDialogItemsForHome(
favorite: Boolean, favorite: Boolean,
canDelete: Boolean, canDelete: Boolean,
actions: MoreDialogActions, actions: MoreDialogActions,
canRemoveContinueWatching: Boolean = false,
canRemoveNextUp: Boolean = false,
): List<DialogItem> = ): List<DialogItem> =
buildList { buildList {
val itemId = item.id 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( add(
DialogItem( DialogItem(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched, text = if (watched) R.string.mark_unwatched else R.string.mark_watched,

View file

@ -22,7 +22,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -47,6 +46,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem 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.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
@ -138,6 +138,12 @@ fun HomePage(
remember { remember {
{ clickedPosition: RowColumn, item: BaseItem -> { clickedPosition: RowColumn, item: BaseItem ->
position = clickedPosition 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 = val dialogItems =
buildMoreDialogItemsForHome( buildMoreDialogItemsForHome(
context = context, context = context,
@ -147,6 +153,8 @@ fun HomePage(
watched = item.played, watched = item.played,
favorite = item.favorite, favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences), canDelete = viewModel.canDelete(item, preferences.appPreferences),
canRemoveNextUp = canRemoveNextUp,
canRemoveContinueWatching = canRemoveContinueWatching,
actions = actions =
MoreDialogActions( MoreDialogActions(
navigateTo = viewModel.navigationManager::navigateTo, navigateTo = viewModel.navigationManager::navigateTo,
@ -164,6 +172,7 @@ fun HomePage(
onClickDelete = { onClickDelete = {
showDeleteDialog = RowColumnItem(position, item) showDeleteDialog = RowColumnItem(position, item)
}, },
onClickRemoveFromNextUp = viewModel::removeFromNextUp,
), ),
) )
dialog = dialog =
@ -260,7 +269,9 @@ fun HomePageContent(
) { ) {
val focusedItem = val focusedItem =
remember(homeRows, position) { 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() } } val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } }
@ -354,7 +365,13 @@ fun HomePageContent(
onClickItem = onClickItem =
remember(rowIndex, onClickItem) { remember(rowIndex, onClickItem) {
{ index, item -> { index, item ->
onClickItem.invoke(RowColumn(rowIndex, index), item) onClickItem.invoke(
RowColumn(
rowIndex,
index,
),
item,
)
} }
}, },
onLongClickItem = onLongClickItem =
@ -378,7 +395,12 @@ fun HomePageContent(
remember(rowIndex, index) { remember(rowIndex, index) {
{ isFocused: Boolean -> { isFocused: Boolean ->
if (isFocused) { if (isFocused) {
currentOnFocusPosition(RowColumn(rowIndex, index)) currentOnFocusPosition(
RowColumn(
rowIndex,
index,
),
)
} }
} }
} }
@ -387,7 +409,10 @@ fun HomePageContent(
{ event: androidx.compose.ui.input.key.KeyEvent -> { event: androidx.compose.ui.input.key.KeyEvent ->
if (isPlayKeyUp(event) && item?.type?.playable == true) { if (isPlayKeyUp(event) && item?.type?.playable == true) {
Timber.v("Clicked play on ${item.id}") Timber.v("Clicked play on ${item.id}")
currentOnClickPlay(currentPosition, item) currentOnClickPlay(
currentPosition,
item,
)
true true
} else { } else {
false false

View file

@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomePageResolvedSettings
import com.github.damontecres.wholphin.services.HomeSettingsService 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.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavDrawerService
@ -39,6 +40,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -58,6 +60,7 @@ class HomeViewModel
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val mediaManagementService: MediaManagementService, private val mediaManagementService: MediaManagementService,
private val latestNextUpService: LatestNextUpService,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(HomeState.EMPTY) private val _state = MutableStateFlow(HomeState.EMPTY)
val state: StateFlow<HomeState> = _state val state: StateFlow<HomeState> = _state
@ -231,6 +234,19 @@ class HomeViewModel
item: BaseItem, item: BaseItem,
appPreferences: AppPreferences, appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, 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( data class HomeState(

View file

@ -38,6 +38,7 @@ fun HomeSettingsGlobal(
onClickLoad: () -> Unit, onClickLoad: () -> Unit,
onClickLoadWeb: () -> Unit, onClickLoadWeb: () -> Unit,
onClickReset: () -> Unit, onClickReset: () -> Unit,
onClickViewNextUp: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val firstFocus: FocusRequester = remember { FocusRequester() } val firstFocus: FocusRequester = remember { FocusRequester() }
@ -123,6 +124,20 @@ fun HomeSettingsGlobal(
) )
} }
item { HorizontalDivider() } 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 { item {
HomeSettingsListItem( HomeSettingsListItem(
selected = false, selected = false,

View file

@ -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.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences 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.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog
@ -61,6 +62,7 @@ fun HomeSettingsPage(
val backStack = rememberNavBackStack(HomeSettingsDestination.RowList) val backStack = rememberNavBackStack(HomeSettingsDestination.RowList)
var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) } var showConfirmDialog by remember { mutableStateOf<ShowConfirm?>(null) }
var searchForDialog by remember { mutableStateOf<BaseItemKind?>(null) } var searchForDialog by remember { mutableStateOf<BaseItemKind?>(null) }
var showRemovedNextUpDialog by remember { mutableStateOf(false) }
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
var position by rememberPosition(0, 0) var position by rememberPosition(0, 0)
@ -284,6 +286,9 @@ fun HomeSettingsPage(
addRow(false) { viewModel.resetToDefault() } addRow(false) { viewModel.resetToDefault() }
} }
}, },
onClickViewNextUp = {
showRemovedNextUpDialog = true
},
modifier = destModifier, modifier = destModifier,
) )
} }
@ -340,6 +345,15 @@ fun HomeSettingsPage(
}, },
) )
} }
if (showRemovedNextUpDialog) {
BasicDialog(
onDismissRequest = { showRemovedNextUpDialog = false },
) {
RemovedNextUpContent(
modifier = Modifier.padding(16.dp),
)
}
}
} }
data class ShowConfirm( data class ShowConfirm(

View file

@ -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<RemovedNextUpState> = _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<RemovedItem>()
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<List<RemovedItem>>)
?.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<List<RemovedItem>> = 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<List<RemovedItem>> -> {
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,
)
}
}
}

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.util package com.github.damontecres.wholphin.util
import com.github.damontecres.wholphin.data.model.BaseItem 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.data.model.HomeRowViewOptions
/** /**
@ -64,6 +65,7 @@ sealed interface HomeRowLoadingState {
override val title: String, override val title: String,
val items: List<BaseItem?>, val items: List<BaseItem?>,
val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), val viewOptions: HomeRowViewOptions = HomeRowViewOptions(),
val rowType: HomeRowConfig? = null,
) : HomeRowLoadingState ) : HomeRowLoadingState
data class Error( data class Error(

View file

@ -6,6 +6,7 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.Encoder
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
object LocalDateSerializer : KSerializer<LocalDate> { object LocalDateSerializer : KSerializer<LocalDate> {
@ -22,3 +23,20 @@ object LocalDateSerializer : KSerializer<LocalDate> {
override fun deserialize(decoder: Decoder): LocalDate = override fun deserialize(decoder: Decoder): LocalDate =
decoder.decodeString().let { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) } decoder.decodeString().let { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) }
} }
object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
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) }
}

View file

@ -749,6 +749,9 @@
<string name="separate_types">Separate types</string> <string name="separate_types">Separate types</string>
<string name="prefer_logos">Prefer showing logos for titles</string> <string name="prefer_logos">Prefer showing logos for titles</string>
<string name="updated_toast">Wholphin updated to %s</string> <string name="updated_toast">Wholphin updated to %s</string>
<string name="remove_continue_watching">Remove from continue watching</string>
<string name="remove_next_up">Remove series from next up</string>
<string name="view_removed_next_up">View series removed from next up</string>
<string name="view_more">View more</string> <string name="view_more">View more</string>
<string name="discover_tv">Discover TV Shows</string> <string name="discover_tv">Discover TV Shows</string>
<string name="discover_movies">Discover Movies</string> <string name="discover_movies">Discover Movies</string>

View file

@ -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<ApiClient>(relaxed = true)
private val mockTvShowsApi = mockk<TvShowsApi>()
private val mockDatePlayedService = mockk<DatePlayedService>()
private val mockDisplayPreferencesService = mockk<DisplayPreferencesService>()
private val mockFavoriteWatchManager = mockk<FavoriteWatchManager>(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<UUID, LocalDateTime>): DisplayPreferencesDto =
testDisplayPreferencesDto.copy(
customPrefs =
mutableMapOf<String, String?>().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<BaseItemDtoQueryResult> =
Response<BaseItemDtoQueryResult>(
content =
BaseItemDtoQueryResult(
items =
listOf(
mockk<BaseItemDto>(relaxed = true) {
every { seriesId } returns seriesId1
every { userData } returns testUserItemDataDto.copy(lastPlayedDate = LocalDateTime.now().minusDays(7))
},
mockk<BaseItemDto>(relaxed = true) {
every { seriesId } returns seriesId2
every { userData } returns testUserItemDataDto.copy(lastPlayedDate = null)
},
mockk<BaseItemDto>(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,
)

View file

@ -1,13 +1,15 @@
package com.github.damontecres.wholphin.test package com.github.damontecres.wholphin.test
import android.content.Context
import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
import com.github.damontecres.wholphin.services.DatePlayedService 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.LatestNextUpService
import com.github.damontecres.wholphin.services.mockQueryResult import com.github.damontecres.wholphin.services.mockQueryResult
import com.github.damontecres.wholphin.services.testDisplayPreferencesDto
import io.mockk.CapturingSlot import io.mockk.CapturingSlot
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
@ -33,15 +35,28 @@ class NextUpTest {
private val mockTvShowsApi = mockk<TvShowsApi>() private val mockTvShowsApi = mockk<TvShowsApi>()
private val mockApi = mockk<ApiClient>(relaxed = true) private val mockApi = mockk<ApiClient>(relaxed = true)
private val mockContext = mockk<Context>()
private val mockDatePlayedService = mockk<DatePlayedService>() private val mockDatePlayedService = mockk<DatePlayedService>()
private val mockDisplayPreferencesService = mockk<DisplayPreferencesService>()
private val mockFavoriteWatchManager = mockk<FavoriteWatchManager>(relaxed = true)
private val latestNextUpService = private val latestNextUpService =
LatestNextUpService(mockContext, mockApi, mockDatePlayedService) LatestNextUpService(
mockApi,
mockDatePlayedService,
mockDisplayPreferencesService,
mockFavoriteWatchManager,
)
@Before @Before
fun setUp() { fun setUp() {
every { mockApi.tvShowsApi } returns mockTvShowsApi every { mockApi.tvShowsApi } returns mockTvShowsApi
coEvery {
mockDisplayPreferencesService.getDisplayPreferences(
any(),
any(),
any(),
)
} returns testDisplayPreferencesDto
} }
@Test @Test

View file

@ -131,6 +131,7 @@ class TestHomeRowSamples {
latestNextUpService = mockk(), latestNextUpService = mockk(),
imageUrlService = mockk(), imageUrlService = mockk(),
suggestionService = mockk(), suggestionService = mockk(),
displayPreferencesService = mockk(),
) )
val str = """{ val str = """{