Request individual seasons from Jellyseerr (#1060)

## Description
Adds a dialog to select individual seasons for a TV show when requesting
it via Discover/Jellyseerr

If a request is already pending, it can be updated. If there's more than
7 seasons, a second submit button is added at the bottom of the dialog
for easier access.

Also, adds a button from Jellyfin series details to its Discover
equivalent and added a request button for partially available series to
request more seasons.

### Related issues
Closes #961

### Testing
Emulator & Jellyseerr 2.7.3

## Screenshots
![discover_seasons
Large](https://github.com/user-attachments/assets/c7781bf4-7c1b-4e4e-a0b8-a572111a8497)

## AI or LLM usage
None
This commit is contained in:
Ray 2026-03-10 11:54:00 -04:00 committed by GitHub
parent 49a6cd8d2f
commit afc47f254b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 512 additions and 46 deletions

View file

@ -2,6 +2,7 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.runtime.Stable
import com.github.damontecres.wholphin.api.seerr.model.CreditCast
import com.github.damontecres.wholphin.api.seerr.model.CreditCrew
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
@ -74,6 +75,7 @@ enum class SeerrAvailability(
/**
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well.
*/
@Stable
@Serializable
data class DiscoverItem(
val id: Int,

View file

@ -57,6 +57,7 @@ class SeerrServerRepository
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
val currentUser: Flow<SeerrUser?> =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
val currentUserId: Flow<Int?> = current.map { it?.config?.id }
/**
* Whether Seerr integration is currently active of not

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import kotlinx.coroutines.flow.first
@ -125,4 +126,16 @@ class SeerrService
} else {
null
}
suspend fun getTvSeries(item: BaseItem): TvDetails? =
if (active.first()) {
item.data.providerIds
?.get("Tmdb")
?.toIntOrNull()
?.let { tvId ->
api.tvApi.tvTvIdGet(tvId = tvId)
}
} else {
null
}
}

View file

@ -35,7 +35,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.Season
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
@ -99,6 +98,7 @@ fun DiscoverSeriesDetails(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var showRequestSeasonDialog by remember { mutableStateOf(false) }
val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k)
@ -159,26 +159,7 @@ fun DiscoverSeriesDetails(
trailers = trailers,
requestOnClick = {
item.id?.let { id ->
if (request4kEnabled) {
moreDialog =
DialogParams(
fromLongClick = false,
title = item.name ?: "",
items =
listOf(
DialogItem(
text = requestStr,
onClick = { viewModel.request(id, false) },
),
DialogItem(
text = request4kStr,
onClick = { viewModel.request(id, true) },
),
),
)
} else {
viewModel.request(id, false)
}
showRequestSeasonDialog = true
}
},
cancelOnClick = {
@ -218,6 +199,18 @@ fun DiscoverSeriesDetails(
waitToLoad = params.fromLongClick,
)
}
if (showRequestSeasonDialog) {
RequestSeasonsDialog(
title = item?.name ?: "",
seasons = seasons,
request4kEnabled = request4kEnabled,
onSubmit = { seasons, is4k ->
item?.id?.let { viewModel.request(it, seasons, is4k) }
showRequestSeasonDialog = false
},
onDismissRequest = { showRequestSeasonDialog = false },
)
}
}
private const val HEADER_ROW = 0
@ -234,7 +227,7 @@ fun DiscoverSeriesDetailsContent(
series: TvDetails,
rating: DiscoverRating?,
canCancel: Boolean,
seasons: List<Season>,
seasons: List<RequestSeason>,
similar: List<DiscoverItem>,
recommended: List<DiscoverItem>,
trailers: List<Trailer>,
@ -299,6 +292,7 @@ fun DiscoverSeriesDetailsContent(
SeerrAvailability.from(series.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN,
requestOnClick = requestOnClick,
pendingOnClick = requestOnClick,
cancelOnClick = cancelOnClick,
moreOnClick = moreOnClick,
goToOnClick = goToOnClick,

View file

@ -6,12 +6,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
import com.github.damontecres.wholphin.api.seerr.model.Season
import com.github.damontecres.wholphin.api.seerr.model.RequestRequestIdPutRequest
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
@ -33,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
@ -63,7 +65,7 @@ class DiscoverSeriesViewModel
val tvSeries = MutableLiveData<TvDetails?>(null)
val rating = MutableLiveData<DiscoverRating?>(null)
val seasons = MutableLiveData<List<Season>>(listOf())
val seasons = MutableLiveData<List<RequestSeason>>(listOf())
val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<DiscoverItem>>(listOf())
val similar = MutableLiveData<List<DiscoverItem>>()
@ -103,6 +105,7 @@ class DiscoverSeriesViewModel
val discoveredItem = DiscoverItem(tv)
backdropService.submit(discoveredItem)
updateSeasonStatus()
updateCanCancel()
withContext(Dispatchers.Main) {
@ -157,6 +160,43 @@ class DiscoverSeriesViewModel
navigationManager.navigateTo(destination)
}
private suspend fun updateSeasonStatus() {
tvSeries.value?.let { tv ->
val seasonStatus = mutableMapOf<Int, SeerrAvailability>()
tv.seasons?.forEach {
it.seasonNumber?.let {
seasonStatus[it] = SeerrAvailability.UNKNOWN
}
}
val tvStatus =
SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN
tv.mediaInfo
?.requests
?.forEach {
it.seasons?.mapNotNull { season ->
season.seasonNumber?.let {
val current = seasonStatus[season.seasonNumber]
val new =
SeerrAvailability
.from(season.status)
?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus
if (current == null || new.status > current.status) {
seasonStatus[season.seasonNumber] = new
}
}
}
}
Timber.v("seasonStatus=%s", seasonStatus)
val requestSeasons =
seasonStatus.mapNotNull { (seasonNumber, availability) ->
tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let {
RequestSeason(it, availability)
}
}
seasons.setValueOnMain(requestSeasons)
}
}
private suspend fun updateCanCancel() {
val user = userConfig.firstOrNull()
val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests)
@ -165,22 +205,45 @@ class DiscoverSeriesViewModel
fun request(
id: Int,
seasons: Set<Int>,
is4k: Boolean,
) {
viewModelScope.launchIO {
val request =
tvSeries.value?.let { tv ->
val currentRequest =
tv.mediaInfo?.requests?.firstOrNull {
it.requestedBy?.id ==
seerrServerRepository.currentUserId.first()
}
if (currentRequest != null) {
Timber.v("User has pending request, will update")
seerrService.api.requestApi.requestRequestIdPut(
requestId = currentRequest.id.toString(),
requestRequestIdPutRequest =
RequestRequestIdPutRequest(
is4k = is4k,
mediaType = RequestRequestIdPutRequest.MediaType.TV,
seasons = seasons.toList(),
),
)
} else {
Timber.v("New request for %s seasons", seasons.size)
seerrService.api.requestApi.requestPost(
RequestPostRequest(
is4k = is4k,
mediaId = id,
mediaType = RequestPostRequest.MediaType.TV,
seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons
seasons = seasons.toList(),
),
)
}
fetchAndSetItem().await()
updateSeasonStatus()
updateCanCancel()
}
}
}
fun cancelRequest(id: Int) {
viewModelScope.launchIO {

View file

@ -37,6 +37,7 @@ fun ExpandableDiscoverButtons(
trailerOnClick: (Trailer) -> Unit,
buttonOnFocusChanged: (FocusState) -> Unit,
modifier: Modifier = Modifier,
pendingOnClick: () -> Unit = {},
) {
val firstFocus = remember { FocusRequester() }
LazyRow(
@ -89,7 +90,7 @@ fun ExpandableDiscoverButtons(
SeerrAvailability.PENDING,
SeerrAvailability.PROCESSING,
-> {
// TODO?
pendingOnClick.invoke()
}
SeerrAvailability.PARTIALLY_AVAILABLE,
@ -109,6 +110,21 @@ fun ExpandableDiscoverButtons(
.onFocusChanged(buttonOnFocusChanged),
)
}
if (availability == SeerrAvailability.PARTIALLY_AVAILABLE) {
item("request_partial") {
ExpandableFaButton(
title = R.string.request,
iconStringRes = R.string.fa_download,
onClick = {
requestOnClick.invoke()
},
enabled = availability == SeerrAvailability.PARTIALLY_AVAILABLE,
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged),
)
}
}
if (canCancel) {
item("cancel") {

View file

@ -0,0 +1,323 @@
package com.github.damontecres.wholphin.ui.detail.discover
import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Switch
import androidx.tv.material3.Text
import androidx.tv.material3.contentColorFor
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.Season
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.ui.cards.AvailableIndicator
import com.github.damontecres.wholphin.ui.cards.PartiallyAvailableIndicator
import com.github.damontecres.wholphin.ui.cards.PendingIndicator
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
data class RequestSeason(
val season: Season,
val availability: SeerrAvailability,
)
@Composable
fun RequestSeasons(
title: String,
seasons: List<RequestSeason>,
onSubmit: (Set<Int>, Boolean) -> Unit,
request4kEnabled: Boolean,
modifier: Modifier,
) {
val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() }
val selected =
remember {
mutableStateSetOf<Int>(
*seasons
.mapNotNull {
if (it.availability > SeerrAvailability.UNKNOWN) {
it.season.seasonNumber
} else {
null
}
}.toTypedArray(),
)
}
var is4k by remember { mutableStateOf(false) }
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
LazyColumn {
item {
val isSelected = selected.containsAll(allSeasonNumbers)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
ClickSwitch(
label = stringResource(R.string.select_all),
checked = isSelected,
onClick = {
if (isSelected) {
selected.removeAll(allSeasonNumbers)
} else {
selected.addAll(allSeasonNumbers)
}
},
)
Button(
onClick = { onSubmit.invoke(selected, is4k) },
) {
Text(
text = stringResource(R.string.submit),
)
}
}
}
if (request4kEnabled) {
item {
ClickSwitch(
label = stringResource(R.string.request_4k),
checked = is4k,
onClick = { is4k = !is4k },
)
}
}
itemsIndexed(seasons) { index, season ->
val seasonNumber = season.season.seasonNumber
val isSelected = seasonNumber in selected
SeasonListItem(
season = season,
selected = isSelected,
onClick = {
if (isSelected) {
selected.remove(seasonNumber)
} else {
seasonNumber?.let { selected.add(it) }
}
},
modifier = Modifier,
)
}
if (seasons.size > 7) {
item {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
) {
Button(
onClick = { onSubmit.invoke(selected, is4k) },
) {
Text(
text = stringResource(R.string.submit),
)
}
}
}
}
}
}
}
@Composable
fun SeasonListItem(
season: RequestSeason,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
selected = false,
headlineContent = {
Text(
text =
season.season.name
?: (stringResource(R.string.tv_season) + " ${season.season.seasonNumber}"),
)
},
supportingContent = {
season.season.episodeCount?.let {
Text(
// TODO should use plurals string
text = "${season.season.episodeCount} " + stringResource(R.string.episodes),
)
}
},
leadingContent = {
when (season.availability) {
SeerrAvailability.UNKNOWN -> {}
SeerrAvailability.DELETED -> {}
SeerrAvailability.PENDING,
SeerrAvailability.PROCESSING,
-> {
PendingIndicator()
}
SeerrAvailability.PARTIALLY_AVAILABLE -> {
PartiallyAvailableIndicator()
}
SeerrAvailability.AVAILABLE -> {
AvailableIndicator()
}
}
},
trailingContent = {
Row {
Switch(
checked = selected,
onCheckedChange = {
onClick.invoke()
},
)
}
},
onClick = onClick,
modifier = modifier,
)
}
@Composable
private fun ClickSurface(
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
) {
Surface(
colors =
ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
focusedContainerColor = MaterialTheme.colorScheme.inverseSurface,
focusedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface),
pressedContainerColor = MaterialTheme.colorScheme.inverseSurface,
pressedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface),
),
onClick = onClick,
content = content,
modifier = modifier,
)
}
@Composable
private fun ClickSwitch(
label: String,
checked: Boolean,
onClick: () -> Unit,
) {
ClickSurface(
onClick = onClick,
modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(horizontal = 8.dp)
.height(54.dp),
) {
Switch(
checked = checked,
onCheckedChange = {},
modifier = Modifier.padding(end = 8.dp),
)
Text(
text = label,
)
}
}
}
@Composable
fun RequestSeasonsDialog(
title: String,
seasons: List<RequestSeason>,
request4kEnabled: Boolean,
onSubmit: (Set<Int>, Boolean) -> Unit,
onDismissRequest: () -> Unit,
) {
BasicDialog(
onDismissRequest = onDismissRequest,
) {
RequestSeasons(
title = title,
seasons = seasons,
request4kEnabled = request4kEnabled,
onSubmit = onSubmit,
modifier = Modifier.padding(16.dp),
)
}
}
@Preview(
device = "spec:parent=tv_1080p",
backgroundColor = 0xFF383535,
uiMode = UI_MODE_TYPE_TELEVISION,
heightDp = 800,
)
@Composable
fun RequestSeasonsPreview() {
val seasons =
List(10) {
RequestSeason(
season =
Season(
seasonNumber = it + 1,
episodeCount = 10 + it,
),
availability =
if (it < 3) {
SeerrAvailability.AVAILABLE
} else {
SeerrAvailability.UNKNOWN
},
)
}
WholphinTheme {
RequestSeasons(
title = "Series title",
seasons = seasons,
request4kEnabled = true,
onSubmit = { _, _ -> },
modifier = Modifier.width(400.dp),
)
}
}

View file

@ -1,6 +1,9 @@
package com.github.damontecres.wholphin.ui.detail.series
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -120,6 +123,7 @@ fun SeriesDetails(
val people by viewModel.people.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf())
val discovered by viewModel.discovered.collectAsState()
val discoverSeries by viewModel.discoverSeries.collectAsState()
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
@ -230,6 +234,12 @@ fun SeriesDetails(
onClickExtra = { _, extra ->
viewModel.navigateTo(extra.destination)
},
discoverSeries = discoverSeries,
onClickDiscoverSeries = {
discoverSeries?.let {
viewModel.navigateTo(Destination.DiscoveredItem(it))
}
},
discovered = discovered,
onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination)
@ -350,6 +360,8 @@ fun SeriesDetailsContent(
onClickExtra: (Int, ExtrasItem) -> Unit,
moreActions: MoreDialogActions,
onClickDiscover: (Int, DiscoverItem) -> Unit,
discoverSeries: DiscoverItem?,
onClickDiscoverSeries: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
@ -487,6 +499,25 @@ fun SeriesDetailsContent(
},
)
}
AnimatedVisibility(
visible = discoverSeries != null,
enter = fadeIn(),
exit = fadeOut(),
) {
ExpandableFaButton(
title = R.string.discover,
iconStringRes = R.string.fa_magnifying_glass_plus,
onClick = onClickDiscoverSeries,
modifier =
Modifier.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
)
}
}
}
item {

View file

@ -58,6 +58,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
@ -123,6 +124,7 @@ class SeriesViewModel
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
val discoverSeries = MutableStateFlow<DiscoverItem?>(null)
val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
@ -232,6 +234,22 @@ class SeriesViewModel
val results = seerrService.similar(item).orEmpty()
discovered.update { results }
}
viewModelScope.launchIO {
seerrService.active.collectLatest { active ->
val tv =
if (active) {
try {
seerrService.getTvSeries(item)?.let { DiscoverItem(it) }
} catch (ex: Exception) {
Timber.e(ex)
null
}
} else {
null
}
discoverSeries.update { tv }
}
}
}
mediaManagementService.deletedItemFlow
.onEach { deletedItem ->

View file

@ -98,6 +98,7 @@ class SwitchSeerrViewModel
)
}
}
serverConnectionStatus.update { LoadingState.Success }
} else {
val message =
results

View file

@ -719,5 +719,6 @@
<string name="creator">Creator</string>
<string name="artist">Artist</string>
<string name="search_for">Search %s</string>
<string name="select_all">Select all</string>
</resources>

View file

@ -1086,6 +1086,11 @@ components:
type: array
items:
$ref: '#/components/schemas/Episode'
status:
type: integer
example: 0
description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED
readOnly: true
TvDetails:
type: object
properties:
@ -1268,6 +1273,10 @@ components:
type: string
type:
type: string
seasons:
type: array
items:
$ref: '#/components/schemas/Season'
required:
- id
- status
@ -6135,16 +6144,10 @@ paths:
type: integer
example: 123
seasons:
# oneOf:
# - type: array
# items:
# type: integer
# minimum: 0
# - type: string
# enum: [all]
# TODO
type: string
enum: [all]
type: array
items:
type: integer
minimum: 0
nullable: true
is4k:
type: boolean