Fix requesting new seasons after approval (#1816)

## Description
Fixes requesting additional seasons of a show if a previous request has
already been approved

Now if the previous request is already approved/completed/etc, a new
request will be created instead of trying to update the previous one.
Pending requests will still be updated as before instead of requesting a
new one.

### Related issues
Fixes #1702

### Testing
Emulator

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-08-12 16:47:08 -04:00 • committed by GitHub
parent 548ec09af3
commit e6304f79a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 161 additions and 41 deletions

View file

@ -71,6 +71,23 @@ enum class SeerrAvailability(
} }
} }
@Serializable
enum class RequestStatus(
val status: Int,
) {
UNKNOWN(0),
PENDING(1),
APPROVED(2),
DECLINED(3),
FAILURE(4),
COMPLETED(5),
;
companion object {
fun from(status: Int?) = RequestStatus.entries.firstOrNull { it.status == status } ?: UNKNOWN
}
}
/** /**
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well, see [availability]. * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well, see [availability].
*/ */

View file

@ -86,7 +86,7 @@ fun DiscoverSeriesDetails(
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current val resources = LocalResources.current
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) val request4kEnabled by viewModel.request4kEnabled.collectAsState()
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
@ -192,6 +192,7 @@ fun DiscoverSeriesDetails(
id = state.tvSeries.successValue?.id ?: -1, id = state.tvSeries.successValue?.id ?: -1,
title = state.tvSeries.successValue?.name ?: "", title = state.tvSeries.successValue?.name ?: "",
seasons = state.seasons, seasons = state.seasons,
seasons4k = state.seasons4k,
request4kEnabled = request4kEnabled, request4kEnabled = request4kEnabled,
onSubmit = { onSubmit = {
showRequestSeasonDialog = false showRequestSeasonDialog = false

View file

@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.RequestStatus
import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
@ -36,10 +37,12 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import timber.log.Timber import timber.log.Timber
@ -66,7 +69,10 @@ class DiscoverSeriesViewModel
val state: StateFlow<DiscoverSeriesState> = _state val state: StateFlow<DiscoverSeriesState> = _state
val userConfig = seerrServerRepository.current.map { it?.config } val userConfig = seerrServerRepository.current.map { it?.config }
val request4kEnabled = seerrServerRepository.current.map { it?.request4kTvEnabled ?: false } val request4kEnabled =
seerrServerRepository.current
.map { it?.request4kTvEnabled ?: false }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
init { init {
init() init()
@ -153,39 +159,99 @@ class DiscoverSeriesViewModel
navigationManager.navigateTo(destination) navigationManager.navigateTo(destination)
} }
private fun updateSeasonStatus(tv: TvDetails) { private suspend fun updateSeasonStatus(tv: TvDetails) {
val seasonStatus = mutableMapOf<Int, SeerrAvailability>() if (request4kEnabled.first()) {
updateSeasonStatus(tv, true)
}
updateSeasonStatus(tv, false)
}
private suspend fun updateSeasonStatus(
tv: TvDetails,
is4k: Boolean,
) {
val currentUserId = seerrServerRepository.currentUserId.first()
val seasonStatus = mutableMapOf<Int, RequestStatus>()
val seasonAvailability = mutableMapOf<Int, SeerrAvailability>()
val editable = mutableMapOf<Int, Boolean>()
tv.seasons?.forEach { tv.seasons?.forEach {
it.seasonNumber?.let { it.seasonNumber?.let { seasonNumber ->
seasonStatus[it] = SeerrAvailability.UNKNOWN seasonStatus[seasonNumber] = RequestStatus.UNKNOWN
val status = if (is4k) it.status4k else it.status
val availability =
SeerrAvailability.from(status) ?: SeerrAvailability.UNKNOWN
seasonAvailability[seasonNumber] = availability
} }
} }
val tvStatus =
SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN
tv.mediaInfo tv.mediaInfo
?.requests ?.requests
?.forEach { ?.filter { it.is4k == is4k }
it.seasons?.mapNotNull { season -> ?.forEach { req ->
req.seasons?.mapNotNull { season ->
season.seasonNumber?.let { season.seasonNumber?.let {
val current = seasonStatus[season.seasonNumber] val current = seasonStatus[season.seasonNumber]
val new = // Not status4k because the request itself is marked as is4k or not
SeerrAvailability val status = season.status
.from(season.status) val new = RequestStatus.from(status)
?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus
if (current == null || new.status > current.status) { if (current == null || new.status > current.status) {
seasonStatus[season.seasonNumber] = new seasonStatus[season.seasonNumber] = new
} }
editable[season.seasonNumber] =
currentUserId == req.requestedBy?.id && req.status == RequestStatus.PENDING.status
} }
} }
} }
Timber.v("seasonAvailability=%s", seasonAvailability)
Timber.v("seasonStatus=%s", seasonStatus) Timber.v("seasonStatus=%s", seasonStatus)
val requestSeasons = val requestSeasons =
seasonStatus.mapNotNull { (seasonNumber, availability) -> seasonStatus.mapNotNull { (seasonNumber, status) ->
tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let { tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let {
RequestSeason(it, availability) val availability =
when (status) {
RequestStatus.UNKNOWN -> {
SeerrAvailability.UNKNOWN
}
RequestStatus.PENDING -> {
SeerrAvailability.PENDING
}
RequestStatus.APPROVED -> {
SeerrAvailability.PROCESSING
}
RequestStatus.DECLINED -> {
SeerrAvailability.UNKNOWN
}
RequestStatus.FAILURE -> {
SeerrAvailability.UNKNOWN
}
RequestStatus.COMPLETED -> {
seasonAvailability.getOrDefault(
seasonNumber,
SeerrAvailability.UNKNOWN,
)
}
}
RequestSeason(
season = it,
status = status,
availability = availability,
editable = editable.getOrDefault(seasonNumber, true),
)
} }
} }
_state.update { it.copy(seasons = requestSeasons) } Timber.v("Got %s seasons, is4k=%s", requestSeasons.size, is4k)
_state.update {
if (is4k) {
it.copy(seasons4k = requestSeasons)
} else {
it.copy(seasons = requestSeasons)
}
}
} }
private suspend fun updateCanCancel() { private suspend fun updateCanCancel() {
@ -225,10 +291,11 @@ class DiscoverSeriesViewModel
fun request(request: TvRequest) { fun request(request: TvRequest) {
viewModelScope.launchIO { viewModelScope.launchIO {
state.value.tvSeries.successValue?.let { tv -> state.value.tvSeries.successValue?.let { tv ->
val currentUserId = seerrServerRepository.currentUserId.first()
val currentRequest = val currentRequest =
tv.mediaInfo?.requests?.firstOrNull { tv.mediaInfo?.requests?.firstOrNull {
it.requestedBy?.id == it.status == RequestStatus.PENDING.status &&
seerrServerRepository.currentUserId.first() it.requestedBy?.id == currentUserId
} }
try { try {
if (currentRequest != null) { if (currentRequest != null) {
@ -311,6 +378,7 @@ data class DiscoverSeriesState(
val tvSeries: DataLoadingState<TvDetails> = DataLoadingState.Pending, val tvSeries: DataLoadingState<TvDetails> = DataLoadingState.Pending,
val rating: DiscoverRating? = null, val rating: DiscoverRating? = null,
val seasons: List<RequestSeason> = emptyList(), val seasons: List<RequestSeason> = emptyList(),
val seasons4k: List<RequestSeason> = emptyList(),
val trailers: List<Trailer> = emptyList(), val trailers: List<Trailer> = emptyList(),
val people: List<DiscoverItem> = emptyList(), val people: List<DiscoverItem> = emptyList(),
val similar: List<DiscoverItem> = emptyList(), val similar: List<DiscoverItem> = emptyList(),

View file

@ -38,6 +38,7 @@ import androidx.tv.material3.Text
import androidx.tv.material3.contentColorFor import androidx.tv.material3.contentColorFor
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.Season import com.github.damontecres.wholphin.api.seerr.model.Season
import com.github.damontecres.wholphin.data.model.RequestStatus
import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.ui.cards.AvailableIndicator import com.github.damontecres.wholphin.ui.cards.AvailableIndicator
import com.github.damontecres.wholphin.ui.cards.PartiallyAvailableIndicator import com.github.damontecres.wholphin.ui.cards.PartiallyAvailableIndicator
@ -51,7 +52,9 @@ import com.github.damontecres.wholphin.util.LoadingState
data class RequestSeason( data class RequestSeason(
val season: Season, val season: Season,
val status: RequestStatus,
val availability: SeerrAvailability, val availability: SeerrAvailability,
val editable: Boolean,
) )
@Composable @Composable
@ -59,26 +62,45 @@ fun RequestSeasons(
id: Int, id: Int,
title: String, title: String,
seasons: List<RequestSeason>, seasons: List<RequestSeason>,
seasons4k: List<RequestSeason>,
data: SeerrRequestData, data: SeerrRequestData,
request4kEnabled: Boolean, request4kEnabled: Boolean,
onSubmit: (TvRequest) -> Unit, onSubmit: (TvRequest) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() } var is4k by remember { mutableStateOf(request4kEnabled) }
val selectedSeasons = val seasons = remember(is4k, seasons, seasons4k) { if (is4k) seasons4k else seasons }
remember {
mutableStateSetOf<Int>( val allSeasonNumbers =
remember(seasons) {
seasons
.filter { it.editable }
.mapNotNull { it.season.seasonNumber }
.toSet()
}
val availableSeasons =
remember(seasons) {
mutableStateSetOf(
*seasons *seasons
.mapNotNull { .filter { season ->
if (it.availability > SeerrAvailability.UNKNOWN) { season.status == RequestStatus.PENDING ||
it.season.seasonNumber season.status == RequestStatus.APPROVED ||
} else { season.status == RequestStatus.COMPLETED ||
null season.availability == SeerrAvailability.PARTIALLY_AVAILABLE ||
} season.availability == SeerrAvailability.AVAILABLE
}.toTypedArray(), }.mapNotNull { season -> season.season.seasonNumber }
.toTypedArray(),
)
}
val selectedSeasons =
remember(seasons) {
mutableStateSetOf<Int>(
*seasons
.filter { season -> season.status == RequestStatus.PENDING }
.mapNotNull { season -> season.season.seasonNumber }
.toTypedArray(),
) )
} }
var is4k by remember { mutableStateOf(request4kEnabled) }
var profile by remember(is4k) { var profile by remember(is4k) {
mutableStateOf( mutableStateOf(
@ -201,12 +223,12 @@ fun RequestSeasons(
} }
itemsIndexed(seasons) { index, season -> itemsIndexed(seasons) { index, season ->
val seasonNumber = season.season.seasonNumber val seasonNumber = season.season.seasonNumber
val isSelected = seasonNumber in selectedSeasons val checked = seasonNumber in selectedSeasons || seasonNumber in availableSeasons
SeasonListItem( SeasonListItem(
season = season, season = season,
selected = isSelected, checked = checked,
onClick = { onClick = {
if (isSelected) { if (checked) {
selectedSeasons.remove(seasonNumber) selectedSeasons.remove(seasonNumber)
} else { } else {
seasonNumber?.let { selectedSeasons.add(it) } seasonNumber?.let { selectedSeasons.add(it) }
@ -242,11 +264,12 @@ fun RequestSeasons(
@Composable @Composable
fun SeasonListItem( fun SeasonListItem(
season: RequestSeason, season: RequestSeason,
selected: Boolean, checked: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
ListItem( ListItem(
enabled = season.editable,
selected = false, selected = false,
headlineContent = { headlineContent = {
Text( Text(
@ -265,9 +288,10 @@ fun SeasonListItem(
}, },
leadingContent = { leadingContent = {
when (season.availability) { when (season.availability) {
SeerrAvailability.UNKNOWN -> {} SeerrAvailability.UNKNOWN,
SeerrAvailability.DELETED,
SeerrAvailability.DELETED -> {} -> {
}
SeerrAvailability.PENDING, SeerrAvailability.PENDING,
SeerrAvailability.PROCESSING, SeerrAvailability.PROCESSING,
@ -285,14 +309,14 @@ fun SeasonListItem(
SeerrAvailability.BLOCKLISTED -> { SeerrAvailability.BLOCKLISTED -> {
// TODO handle block listed // TODO handle block listed
// BlocklistedIndicator() // BlocklistedIndicator()
} }
} }
}, },
trailingContent = { trailingContent = {
Row { Row {
Switch( Switch(
checked = selected, checked = checked,
onCheckedChange = { onCheckedChange = {
onClick.invoke() onClick.invoke()
}, },
@ -362,6 +386,7 @@ fun RequestSeasonsDialog(
loading: LoadingState, loading: LoadingState,
data: SeerrRequestData, data: SeerrRequestData,
seasons: List<RequestSeason>, seasons: List<RequestSeason>,
seasons4k: List<RequestSeason>,
request4kEnabled: Boolean, request4kEnabled: Boolean,
onSubmit: (TvRequest) -> Unit, onSubmit: (TvRequest) -> Unit,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
@ -388,6 +413,7 @@ fun RequestSeasonsDialog(
title = title, title = title,
data = data, data = data,
seasons = seasons, seasons = seasons,
seasons4k = seasons4k,
request4kEnabled = request4kEnabled, request4kEnabled = request4kEnabled,
onSubmit = onSubmit, onSubmit = onSubmit,
modifier = modifier =
@ -416,12 +442,14 @@ fun RequestSeasonsPreview() {
seasonNumber = it + 1, seasonNumber = it + 1,
episodeCount = 10 + it, episodeCount = 10 + it,
), ),
status = RequestStatus.UNKNOWN,
availability = availability =
if (it < 3) { if (it < 3) {
SeerrAvailability.AVAILABLE SeerrAvailability.AVAILABLE
} else { } else {
SeerrAvailability.UNKNOWN SeerrAvailability.UNKNOWN
}, },
editable = it >= 3,
) )
} }
@ -430,6 +458,7 @@ fun RequestSeasonsPreview() {
id = 1, id = 1,
title = "Series title", title = "Series title",
seasons = seasons, seasons = seasons,
seasons4k = emptyList(),
data = data =
SeerrRequestData( SeerrRequestData(
profiles4k = profiles4k =

View file

@ -1090,7 +1090,12 @@ components:
status: status:
type: integer type: integer
example: 0 example: 0
description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED description: Availability of the season
readOnly: true
status4k:
type: integer
example: 0
description: Availability of the season in 4K
readOnly: true readOnly: true
TvShowStatus: TvShowStatus:
type: string type: string