From e6304f79a2c70f2e22b08de3048cf69f0966e63e Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 12 Aug 2026 16:47:08 -0400 Subject: [PATCH] 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 --- .../wholphin/data/model/DiscoverItem.kt | 17 +++ .../detail/discover/DiscoverSeriesDetails.kt | 3 +- .../discover/DiscoverSeriesViewModel.kt | 104 +++++++++++++++--- .../ui/detail/discover/RequestSeasons.kt | 71 ++++++++---- app/src/main/seerr/seerr-api.yml | 7 +- 5 files changed, 161 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt index ef11aff3..5c5c7c40 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -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]. */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index 947dae96..d02b17eb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -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(null) } var seasonDialog by remember { mutableStateOf(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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt index 4a9eb3f2..3efc3f6e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -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 = _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() + 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() + val seasonAvailability = mutableMapOf() + val editable = mutableMapOf() 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 = DataLoadingState.Pending, val rating: DiscoverRating? = null, val seasons: List = emptyList(), + val seasons4k: List = emptyList(), val trailers: List = emptyList(), val people: List = emptyList(), val similar: List = emptyList(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt index 7adb018e..46c74c58 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt @@ -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, + seasons4k: List, data: SeerrRequestData, request4kEnabled: Boolean, onSubmit: (TvRequest) -> Unit, modifier: Modifier = Modifier, ) { - val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() } - val selectedSeasons = - remember { - mutableStateSetOf( + 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( + *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, + seasons4k: List, 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 = diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index 3581c599..c9c617cf 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -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