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].
*/

View file

@ -86,7 +86,7 @@ fun DiscoverSeriesDetails(
val context = LocalContext.current
val resources = LocalResources.current
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 seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
@ -192,6 +192,7 @@ fun DiscoverSeriesDetails(
id = state.tvSeries.successValue?.id ?: -1,
title = state.tvSeries.successValue?.name ?: "",
seasons = state.seasons,
seasons4k = state.seasons4k,
request4kEnabled = request4kEnabled,
onSubmit = {
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.DiscoverRating
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.SeerrItemType
import com.github.damontecres.wholphin.data.model.Trailer
@ -36,10 +37,12 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.api.client.ApiClient
import timber.log.Timber
@ -66,7 +69,10 @@ class DiscoverSeriesViewModel
val state: StateFlow<DiscoverSeriesState> = _state
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()
@ -153,39 +159,99 @@ class DiscoverSeriesViewModel
navigationManager.navigateTo(destination)
}
private fun updateSeasonStatus(tv: TvDetails) {
val seasonStatus = mutableMapOf<Int, SeerrAvailability>()
private suspend fun updateSeasonStatus(tv: TvDetails) {
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 {
it.seasonNumber?.let {
seasonStatus[it] = SeerrAvailability.UNKNOWN
it.seasonNumber?.let { seasonNumber ->
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
?.requests
?.forEach {
it.seasons?.mapNotNull { season ->
?.filter { it.is4k == is4k }
?.forEach { req ->
req.seasons?.mapNotNull { season ->
season.seasonNumber?.let {
val current = seasonStatus[season.seasonNumber]
val new =
SeerrAvailability
.from(season.status)
?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus
// Not status4k because the request itself is marked as is4k or not
val status = season.status
val new = RequestStatus.from(status)
if (current == null || new.status > current.status) {
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)
val requestSeasons =
seasonStatus.mapNotNull { (seasonNumber, availability) ->
seasonStatus.mapNotNull { (seasonNumber, status) ->
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() {
@ -225,10 +291,11 @@ class DiscoverSeriesViewModel
fun request(request: TvRequest) {
viewModelScope.launchIO {
state.value.tvSeries.successValue?.let { tv ->
val currentUserId = seerrServerRepository.currentUserId.first()
val currentRequest =
tv.mediaInfo?.requests?.firstOrNull {
it.requestedBy?.id ==
seerrServerRepository.currentUserId.first()
it.status == RequestStatus.PENDING.status &&
it.requestedBy?.id == currentUserId
}
try {
if (currentRequest != null) {
@ -311,6 +378,7 @@ data class DiscoverSeriesState(
val tvSeries: DataLoadingState<TvDetails> = DataLoadingState.Pending,
val rating: DiscoverRating? = null,
val seasons: List<RequestSeason> = emptyList(),
val seasons4k: List<RequestSeason> = emptyList(),
val trailers: List<Trailer> = emptyList(),
val people: 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 com.github.damontecres.wholphin.R
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.ui.cards.AvailableIndicator
import com.github.damontecres.wholphin.ui.cards.PartiallyAvailableIndicator
@ -51,7 +52,9 @@ import com.github.damontecres.wholphin.util.LoadingState
data class RequestSeason(
val season: Season,
val status: RequestStatus,
val availability: SeerrAvailability,
val editable: Boolean,
)
@Composable
@ -59,26 +62,45 @@ fun RequestSeasons(
id: Int,
title: String,
seasons: List<RequestSeason>,
seasons4k: List<RequestSeason>,
data: SeerrRequestData,
request4kEnabled: Boolean,
onSubmit: (TvRequest) -> Unit,
modifier: Modifier = Modifier,
) {
val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() }
val selectedSeasons =
remember {
mutableStateSetOf<Int>(
var is4k by remember { mutableStateOf(request4kEnabled) }
val seasons = remember(is4k, seasons, seasons4k) { if (is4k) seasons4k else seasons }
val allSeasonNumbers =
remember(seasons) {
seasons
.filter { it.editable }
.mapNotNull { it.season.seasonNumber }
.toSet()
}
val availableSeasons =
remember(seasons) {
mutableStateSetOf(
*seasons
.mapNotNull {
if (it.availability > SeerrAvailability.UNKNOWN) {
it.season.seasonNumber
} else {
null
}
}.toTypedArray(),
.filter { season ->
season.status == RequestStatus.PENDING ||
season.status == RequestStatus.APPROVED ||
season.status == RequestStatus.COMPLETED ||
season.availability == SeerrAvailability.PARTIALLY_AVAILABLE ||
season.availability == SeerrAvailability.AVAILABLE
}.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) {
mutableStateOf(
@ -201,12 +223,12 @@ fun RequestSeasons(
}
itemsIndexed(seasons) { index, season ->
val seasonNumber = season.season.seasonNumber
val isSelected = seasonNumber in selectedSeasons
val checked = seasonNumber in selectedSeasons || seasonNumber in availableSeasons
SeasonListItem(
season = season,
selected = isSelected,
checked = checked,
onClick = {
if (isSelected) {
if (checked) {
selectedSeasons.remove(seasonNumber)
} else {
seasonNumber?.let { selectedSeasons.add(it) }
@ -242,11 +264,12 @@ fun RequestSeasons(
@Composable
fun SeasonListItem(
season: RequestSeason,
selected: Boolean,
checked: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
enabled = season.editable,
selected = false,
headlineContent = {
Text(
@ -265,9 +288,10 @@ fun SeasonListItem(
},
leadingContent = {
when (season.availability) {
SeerrAvailability.UNKNOWN -> {}
SeerrAvailability.DELETED -> {}
SeerrAvailability.UNKNOWN,
SeerrAvailability.DELETED,
-> {
}
SeerrAvailability.PENDING,
SeerrAvailability.PROCESSING,
@ -285,14 +309,14 @@ fun SeasonListItem(
SeerrAvailability.BLOCKLISTED -> {
// TODO handle block listed
// BlocklistedIndicator()
// BlocklistedIndicator()
}
}
},
trailingContent = {
Row {
Switch(
checked = selected,
checked = checked,
onCheckedChange = {
onClick.invoke()
},
@ -362,6 +386,7 @@ fun RequestSeasonsDialog(
loading: LoadingState,
data: SeerrRequestData,
seasons: List<RequestSeason>,
seasons4k: List<RequestSeason>,
request4kEnabled: Boolean,
onSubmit: (TvRequest) -> Unit,
onDismissRequest: () -> Unit,
@ -388,6 +413,7 @@ fun RequestSeasonsDialog(
title = title,
data = data,
seasons = seasons,
seasons4k = seasons4k,
request4kEnabled = request4kEnabled,
onSubmit = onSubmit,
modifier =
@ -416,12 +442,14 @@ fun RequestSeasonsPreview() {
seasonNumber = it + 1,
episodeCount = 10 + it,
),
status = RequestStatus.UNKNOWN,
availability =
if (it < 3) {
SeerrAvailability.AVAILABLE
} else {
SeerrAvailability.UNKNOWN
},
editable = it >= 3,
)
}
@ -430,6 +458,7 @@ fun RequestSeasonsPreview() {
id = 1,
title = "Series title",
seasons = seasons,
seasons4k = emptyList(),
data =
SeerrRequestData(
profiles4k =

View file

@ -1090,7 +1090,12 @@ components:
status:
type: integer
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
TvShowStatus:
type: string