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,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
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<TvShowsApi>()
private val mockApi = mockk<ApiClient>(relaxed = true)
private val mockContext = mockk<Context>()
private val mockDatePlayedService = mockk<DatePlayedService>()
private val mockDisplayPreferencesService = mockk<DisplayPreferencesService>()
private val mockFavoriteWatchManager = mockk<FavoriteWatchManager>(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

View file

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