Better handling for Seerr request permissions & minor discover UI tweaks (#2084)

## Description
No longer assume that if a user has "Request 4K" then they must also
have regular "Request". Now the requesting dialogs will handle:
- User has only "Request" (including just "Request Movie" vs "Request
TV")
- User has only "Request 4K"
- User has both

Also fixes an issue where the request button would be disabled if a
previous request was declined.

And also because it helped me with testing this and is useful, the
dialog in settings to remove a Seerr server now also shows the username
in addition to URL.

### Related issues

https://github.com/damontecres/Wholphin/issues/702#issuecomment-6132914691

### Testing
Manually on the emulator with the test cases above. I also tested with
and without "Advanced Requests" permission

## Screenshots
N/A, the dialog change is minor

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-10-19 13:35:51 -04:00 • committed by GitHub
parent ebf44699ca
commit e273195e93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 170 additions and 70 deletions

View file

@ -54,3 +54,11 @@ fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean {
permission.parents.any { hasPermission(it) } ||
SeerrPermission.ADMIN.hasPermission(permissions)
}
val SeerrUserConfig?.canRequestMovies: Boolean
get() =
hasPermission(SeerrPermission.REQUEST_MOVIE) || hasPermission(SeerrPermission.REQUEST_4K_MOVIE)
val SeerrUserConfig?.canRequestTv: Boolean
get() =
hasPermission(SeerrPermission.REQUEST_TV) || hasPermission(SeerrPermission.REQUEST_4K_TV)

View file

@ -205,11 +205,15 @@ data class CurrentSeerr(
val config: SeerrUserConfig,
val serverConfig: PublicSettings,
) {
val requestMovieEnabled: Boolean
get() = config.hasPermission(SeerrPermission.REQUEST_MOVIE)
val request4kMovieEnabled: Boolean
get() =
(serverConfig.movie4kEnabled ?: false) &&
config.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)
val requestTvEnabled: Boolean
get() = config.hasPermission(SeerrPermission.REQUEST_TV)
val request4kTvEnabled: Boolean
get() =
(serverConfig.series4kEnabled ?: false) &&

View file

@ -231,28 +231,35 @@ fun DialogPopup(
onDismissRequest = onDismissRequest,
properties = properties,
) {
val elevatedContainerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
DialogPopupContent(
title = title,
dialogItems = dialogItems,
waiting = waiting,
onDismissRequest = onDismissRequest,
dismissOnClick = dismissOnClick,
elevation = elevation,
modifier =
Modifier.onKeyEvent { event ->
val code = event.nativeKeyEvent.keyCode
if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP &&
code in
setOf(
KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_NUMPAD_ENTER,
)
) {
waiting = false
}
false
},
Modifier
.onKeyEvent { event ->
val code = event.nativeKeyEvent.keyCode
if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP &&
code in
setOf(
KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_NUMPAD_ENTER,
)
) {
waiting = false
}
false
}.shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp))
.graphicsLayer {
this.clip = true
this.shape = RoundedCornerShape(28.0.dp)
}.drawBehind { drawRect(color = elevatedContainerColor) }
.padding(PaddingValues(24.dp)),
)
}
}
@ -266,20 +273,10 @@ fun DialogPopupContent(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
dismissOnClick: Boolean = true,
elevation: Dp = 8.dp,
) {
val elevatedContainerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(elevation)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier =
modifier
.shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp))
.graphicsLayer {
this.clip = true
this.shape = RoundedCornerShape(28.0.dp)
}.drawBehind { drawRect(color = elevatedContainerColor) }
.padding(PaddingValues(24.dp)),
modifier = modifier,
) {
Text(
text = title,

View file

@ -42,9 +42,8 @@ import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.data.model.canRequestMovies
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.SeerrUserConfig
import com.github.damontecres.wholphin.services.TrailerService
@ -84,14 +83,12 @@ fun DiscoverMovieDetails(
}
val state by viewModel.state.collectAsState()
val userConfig by viewModel.userConfig.collectAsState(null)
val requestEnabled by viewModel.requestEnabled.collectAsState(false)
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showRequestDialog by remember { mutableStateOf(false) }
val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k)
when (val st = state.movie) {
is DataLoadingState.Error -> {
ErrorMessage(st, modifier)
@ -153,6 +150,7 @@ fun DiscoverMovieDetails(
RequestMovieDialog(
loading = state.profileLoading,
data = state.requestData,
requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled,
movie = movie,
onSubmit = {
@ -253,7 +251,7 @@ fun DiscoverMovieDetailsContent(
}
}
},
canRequest = userConfig.hasPermission(SeerrPermission.REQUEST),
canRequest = userConfig.canRequestMovies,
canCancel = canCancel,
trailers = trailers,
trailerOnClick = trailerOnClick,

View file

@ -72,6 +72,8 @@ class DiscoverMovieViewModel
val state: StateFlow<DiscoverMovieState> = _state
val userConfig = seerrServerRepository.current.map { it?.config }
val requestEnabled =
seerrServerRepository.current.map { it?.requestMovieEnabled ?: false }
val request4kEnabled =
seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
@ -191,7 +193,7 @@ class DiscoverMovieViewModel
return@launchIO
}
try {
seerrService.api.requestApi.requestPost(
val postRequest =
RequestPostRequest(
is4k = request.is4k,
mediaId = request.movieId,
@ -205,8 +207,9 @@ class DiscoverMovieViewModel
profileId = request.profileId,
rootFolder = request.folder,
tags = emptyList(),
),
)
)
Timber.v("postRequest=%s", postRequest)
seerrService.api.requestApi.requestPost(postRequest)
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {

View file

@ -36,9 +36,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.data.model.canRequestTv
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.SeerrUserConfig
import com.github.damontecres.wholphin.services.TrailerService
@ -87,6 +86,7 @@ fun DiscoverSeriesDetails(
val context = LocalContext.current
val resources = LocalResources.current
val state by viewModel.state.collectAsState()
val requestEnabled by viewModel.requestEnabled.collectAsState()
val request4kEnabled by viewModel.request4kEnabled.collectAsState()
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
@ -111,6 +111,7 @@ fun DiscoverSeriesDetails(
DiscoverSeriesDetailsContent(
preferences = preferences,
series = item,
availability = state.availability,
userConfig = userConfig,
rating = state.rating,
canCancel = state.canCancelRequest,
@ -187,6 +188,7 @@ fun DiscoverSeriesDetails(
title = state.tvSeries.successValue?.name ?: "",
seasons = state.seasons,
seasons4k = state.seasons4k,
requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled,
onSubmit = {
showRequestSeasonDialog = false
@ -211,6 +213,7 @@ fun DiscoverSeriesDetailsContent(
preferences: UserPreferences,
userConfig: SeerrUserConfig?,
series: TvDetails,
availability: SeerrAvailability,
rating: DiscoverRating?,
canCancel: Boolean,
seasons: List<RequestSeason>,
@ -274,9 +277,7 @@ fun DiscoverSeriesDetailsContent(
.padding(top = HeaderUtils.topPadding, bottom = 16.dp),
)
ExpandableDiscoverButtons(
availability =
SeerrAvailability.from(series.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN,
availability = availability,
requestOnClick = requestOnClick,
pendingOnClick = requestOnClick,
cancelOnClick = cancelOnClick,
@ -290,7 +291,7 @@ fun DiscoverSeriesDetailsContent(
}
}
},
canRequest = userConfig.hasPermission(SeerrPermission.REQUEST),
canRequest = userConfig.canRequestTv,
canCancel = canCancel,
trailers = trailers,
trailerOnClick = trailerOnClick,

View file

@ -72,6 +72,10 @@ class DiscoverSeriesViewModel
val state: StateFlow<DiscoverSeriesState> = _state
val userConfig = seerrServerRepository.current.map { it?.config }
val requestEnabled =
seerrServerRepository.current
.map { it?.requestTvEnabled ?: false }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val request4kEnabled =
seerrServerRepository.current
.map { it?.request4kTvEnabled ?: false }
@ -105,7 +109,7 @@ class DiscoverSeriesViewModel
backdropService.submit(discoveredItem)
updateSeasonStatus(tv)
updateCanCancel()
updateAvailability()
viewModelScope.launchIO {
val rating =
@ -185,6 +189,7 @@ class DiscoverSeriesViewModel
tv: TvDetails,
is4k: Boolean,
) {
Timber.v("updateSeasonStatus: id=%s, is4k=%s", tv.id, is4k)
val currentUserId = seerrServerRepository.currentUserId.first()
val seasonStatus = mutableMapOf<Int, RequestStatus>()
val seasonAvailability = mutableMapOf<Int, SeerrAvailability>()
@ -225,7 +230,10 @@ class DiscoverSeriesViewModel
seasonStatus[season.seasonNumber] = new
}
editable[season.seasonNumber] =
currentUserId == req.requestedBy?.id && req.status == RequestStatus.PENDING.status
currentUserId == req.requestedBy?.id && (
req.status == RequestStatus.PENDING.status ||
req.status == RequestStatus.DECLINED.status
)
}
}
}
@ -294,7 +302,7 @@ class DiscoverSeriesViewModel
}
}
private suspend fun updateCanCancel() {
private suspend fun updateAvailability() {
val user = userConfig.firstOrNull()
val canCancel =
canUserCancelRequest(
@ -303,7 +311,32 @@ class DiscoverSeriesViewModel
?.mediaInfo
?.requests,
)
_state.update { it.copy(canCancelRequest = canCancel) }
val status =
state.value.tvSeries.successValue?.let { tv ->
val overallStatus = SeerrAvailability.from(tv.mediaInfo?.status)
if (overallStatus == SeerrAvailability.UNKNOWN) {
val userRequests =
tv.mediaInfo
?.requests
?.filter { it.requestedBy?.id == user?.id }
.orEmpty()
if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.PENDING }) {
SeerrAvailability.PENDING
} else if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.APPROVED }) {
SeerrAvailability.PROCESSING
} else {
SeerrAvailability.UNKNOWN
}
} else {
overallStatus
}
}
_state.update {
it.copy(
canCancelRequest = canCancel,
availability = status ?: SeerrAvailability.UNKNOWN,
)
}
}
fun requestOnClick() {
@ -386,7 +419,7 @@ class DiscoverSeriesViewModel
fetchAndSetItem().await()?.let {
updateSeasonStatus(it)
updateCanCancel()
updateAvailability()
}
}
}
@ -407,7 +440,7 @@ class DiscoverSeriesViewModel
fetchAndSetItem().await()?.let {
updateSeasonStatus(it)
updateCanCancel()
updateAvailability()
}
}
}
@ -426,4 +459,5 @@ data class DiscoverSeriesState(
val canCancelRequest: Boolean = false,
val profileLoading: LoadingState = LoadingState.Pending,
val requestData: SeerrRequestData = SeerrRequestData(),
val availability: SeerrAvailability = SeerrAvailability.UNKNOWN,
)

View file

@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.util.LoadingState
fun RequestMovieDialog(
loading: LoadingState,
data: SeerrRequestData,
requestEnabled: Boolean,
request4kEnabled: Boolean,
movie: MovieDetails,
onSubmit: (MovieRequest) -> Unit,
@ -54,10 +55,11 @@ fun RequestMovieDialog(
RequestMovie(
loading = loading,
data = data,
requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled,
movie = movie,
onSubmit = onSubmit,
modifier = Modifier.padding(16.dp),
modifier = Modifier.padding(24.dp),
)
}
}
@ -66,6 +68,7 @@ fun RequestMovieDialog(
fun RequestMovie(
loading: LoadingState,
data: SeerrRequestData,
requestEnabled: Boolean,
request4kEnabled: Boolean,
movie: MovieDetails,
onSubmit: (MovieRequest) -> Unit,
@ -90,7 +93,8 @@ fun RequestMovie(
if (data.profiles.isEmpty() && data.rootFolders.isEmpty() &&
data.profiles4k.isEmpty() && data.rootFolders4k.isEmpty()
) {
if (request4kEnabled) {
// Both enabled, show choice
if (requestEnabled && request4kEnabled) {
val items =
remember {
listOf(
@ -118,12 +122,13 @@ fun RequestMovie(
)
} else {
LaunchedEffect(Unit) {
onSubmit.invoke(MovieRequest(data, movie.id, false, null, null))
onSubmit.invoke(MovieRequest(data, movie.id, request4kEnabled, null, null))
}
}
} else {
RequestMovieWithOptions(
data = data,
requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled,
movie = movie,
onSubmit = onSubmit,
@ -137,12 +142,13 @@ fun RequestMovie(
@Composable
private fun RequestMovieWithOptions(
data: SeerrRequestData,
requestEnabled: Boolean,
request4kEnabled: Boolean,
movie: MovieDetails,
onSubmit: (MovieRequest) -> Unit,
modifier: Modifier = Modifier,
) {
var is4k by remember { mutableStateOf(request4kEnabled) }
var is4k by remember { mutableStateOf(!requestEnabled) }
var profile by remember(is4k) {
mutableStateOf(
if (is4k) {
@ -185,7 +191,7 @@ private fun RequestMovieWithOptions(
.fillMaxWidth()
.padding(bottom = 8.dp),
) {
if (request4kEnabled) {
if (requestEnabled && request4kEnabled) {
ClickSwitch(
label = stringResource(R.string.request_4k),
checked = is4k,
@ -377,7 +383,7 @@ fun MovieRequestPreview() {
),
profiles4k =
listOf(
SeerrProfile(1, "HD", true),
SeerrProfile(1, "4K", true),
SeerrProfile(2, "Ultra HD", false),
),
rootFolders4k =
@ -385,7 +391,8 @@ fun MovieRequestPreview() {
SeerrRootFolder(1, "/movies", "400GB", true),
),
),
request4kEnabled = false,
requestEnabled = true,
request4kEnabled = true,
movie = MovieDetails(id = 1, title = "Movie Name", releaseDate = "2026"),
onSubmit = {},
modifier =

View file

@ -65,11 +65,12 @@ fun RequestSeasons(
seasons: List<RequestSeason>,
seasons4k: List<RequestSeason>,
data: SeerrRequestData,
requestEnabled: Boolean,
request4kEnabled: Boolean,
onSubmit: (TvRequest) -> Unit,
modifier: Modifier = Modifier,
) {
var is4k by remember { mutableStateOf(request4kEnabled) }
var is4k by remember { mutableStateOf(!requestEnabled) }
val seasons = remember(is4k, seasons, seasons4k) { if (is4k) seasons4k else seasons }
val allSeasonNumbers =
@ -161,7 +162,7 @@ fun RequestSeasons(
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
if (request4kEnabled) {
if (requestEnabled && request4kEnabled) {
ClickSwitch(
label = stringResource(R.string.request_4k),
checked = is4k,
@ -391,6 +392,7 @@ fun RequestSeasonsDialog(
data: SeerrRequestData,
seasons: List<RequestSeason>,
seasons4k: List<RequestSeason>,
requestEnabled: Boolean,
request4kEnabled: Boolean,
onSubmit: (TvRequest) -> Unit,
onDismissRequest: () -> Unit,
@ -418,6 +420,7 @@ fun RequestSeasonsDialog(
data = data,
seasons = seasons,
seasons4k = seasons4k,
requestEnabled = requestEnabled,
request4kEnabled = request4kEnabled,
onSubmit = onSubmit,
modifier =
@ -475,6 +478,7 @@ fun RequestSeasonsPreview() {
SeerrRootFolder(1, "/tv", "400GB", true),
),
),
requestEnabled = true,
request4kEnabled = true,
onSubmit = { },
modifier = Modifier.width(400.dp),

View file

@ -1,6 +1,13 @@
package com.github.damontecres.wholphin.ui.preferences
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.preferences.AppPreference
import kotlinx.serialization.Serializable
@ -45,3 +52,40 @@ enum class PreferenceScreenOption {
fun fromString(name: String?) = entries.firstOrNull { it.name == name } ?: BASIC
}
}
@Composable
fun rememberSeerrDialogBody(
user: SeerrUser,
server: SeerrServer,
ex: Exception? = null,
): String {
val username =
when (user.authMethod) {
SeerrAuthMethod.LOCAL,
SeerrAuthMethod.JELLYFIN,
-> user.username ?: stringResource(R.string.unknown)
SeerrAuthMethod.API_KEY -> stringResource(R.string.api_key)
}
val method =
when (user.authMethod) {
SeerrAuthMethod.LOCAL -> R.string.seerr_local_user
SeerrAuthMethod.JELLYFIN -> R.string.seerr_jellyfin_user
SeerrAuthMethod.API_KEY -> 0
}.let {
if (it != 0) " (${stringResource(it)})" else ""
}
val body =
"""
${stringResource(R.string.url)}: ${server.url}
${stringResource(R.string.username)}: $username$method
""".trimIndent() +
if (ex != null) {
"\n\n${stringResource(R.string.voice_error_server)}: ${ex.localizedMessage}"
} else {
""
}
return remember { body }
}

View file

@ -46,6 +46,8 @@ import androidx.tv.material3.surfaceColorAtElevation
import coil3.SingletonImageLoader
import coil3.imageLoader
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
@ -62,6 +64,7 @@ import com.github.damontecres.wholphin.preferences.experimentalPreferences
import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling
import com.github.damontecres.wholphin.preferences.screensaverPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.services.CurrentSeerr
import com.github.damontecres.wholphin.services.Release
import com.github.damontecres.wholphin.services.SeerrConnectionStatus
import com.github.damontecres.wholphin.services.UpdateChecker
@ -478,7 +481,8 @@ fun PreferencesContent(
when (val conn = seerrConnection) {
is SeerrConnectionStatus.Error -> {
SeerrDialogMode.Error(
conn.server.url,
conn.server,
conn.user,
conn.ex,
)
}
@ -489,7 +493,7 @@ fun PreferencesContent(
is SeerrConnectionStatus.Success -> {
SeerrDialogMode.Remove(
conn.current.server.url,
conn.current,
)
}
}
@ -701,14 +705,16 @@ fun PreferencesContent(
}
when (val mode = seerrDialogMode) {
is SeerrDialogMode.Remove -> {
val body = rememberSeerrDialogBody(mode.current.user, mode.current.server)
ConfirmDialog(
title = stringResource(R.string.remove_seerr_server),
body = mode.serverUrl,
body = body,
onCancel = { seerrDialogMode = SeerrDialogMode.None },
onConfirm = {
seerrVm.removeServer()
seerrDialogMode = SeerrDialogMode.None
},
elevation = 3.dp,
)
}
@ -732,15 +738,7 @@ fun PreferencesContent(
}
is SeerrDialogMode.Error -> {
val errorStr = stringResource(R.string.voice_error_server)
val body =
remember(mode) {
"""
${mode.serverUrl}
$errorStr: ${mode.ex.localizedMessage}
""".trimIndent()
}
val body = rememberSeerrDialogBody(mode.user, mode.server, mode.ex)
ConfirmDialog(
title = stringResource(R.string.remove_seerr_server),
body = body,
@ -750,6 +748,7 @@ fun PreferencesContent(
seerrDialogMode = SeerrDialogMode.None
},
bodyColor = MaterialTheme.colorScheme.error,
elevation = 3.dp,
)
}
@ -865,11 +864,12 @@ private sealed interface SeerrDialogMode {
data object Add : SeerrDialogMode
data class Remove(
val serverUrl: String,
val current: CurrentSeerr,
) : SeerrDialogMode
data class Error(
val serverUrl: String,
val server: SeerrServer,
val user: SeerrUser,
val ex: Exception,
) : SeerrDialogMode
}