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

@ -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
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) {
}
}

View file

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

View file

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