Choose Seerr quality profile & root folder (#1604)

## Description
Adds option to select the quality profile and/or root folder when
requesting from Seerr.

The user needs "Advanced Requests" permission to use this feature. If
not, the options won't show.

### Related issues
Closes #863
Related to #702

### Testing
Emulator w/ different user permissions and different request
configurations

## Screenshots
<img width="1280" height="720" alt="request_movie Large"
src="https://github.com/user-attachments/assets/2665dcbb-e6c3-4d26-857c-b465edff6a02"
/>


## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-06-22 18:37:16 -04:00 • committed by GitHub
parent 4431757705
commit 77d7571276
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 899 additions and 138 deletions

View file

@ -6,13 +6,21 @@ import com.github.damontecres.wholphin.api.seerr.model.CreditCrew
import com.github.damontecres.wholphin.api.seerr.model.MediaInfo
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.api.seerr.model.MovieResult
import com.github.damontecres.wholphin.api.seerr.model.RootFolder
import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner
import com.github.damontecres.wholphin.api.seerr.model.ServiceProfile
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.api.seerr.model.TvResult
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.ui.detail.discover.SeerrProfile
import com.github.damontecres.wholphin.ui.detail.discover.SeerrRequestData
import com.github.damontecres.wholphin.ui.detail.discover.SeerrRootFolder
import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.toLocalDate
import kotlinx.coroutines.flow.first
@ -191,6 +199,126 @@ class SeerrService
return "${base}${prefix}$path"
}
suspend fun getProfilesAndFolders(type: SeerrItemType): SeerrRequestData {
var result = SeerrRequestData()
val current = seerrServerRepository.current.firstOrNull()
if (current != null && current.config.hasPermission(SeerrPermission.REQUEST_ADVANCED)) {
if (type == SeerrItemType.MOVIE) {
val radarrs = api.serviceApi.serviceRadarrGet()
val radarr =
radarrs.firstOrNull { it.isDefault && !it.is4k }
?: radarrs.firstOrNull { !it.is4k }
val radarr4k =
radarrs.firstOrNull { it.isDefault && it.is4k }
?: radarrs.firstOrNull { it.is4k }
if (radarr != null && radarr.id != null) {
val settings = api.serviceApi.serviceRadarrRadarrIdGet(radarr.id)
setProfilesAndFolder(
settings.profiles,
settings.rootFolders,
settings.server?.activeProfileId,
settings.server?.activeDirectory,
) { profiles, rootFolders ->
result =
result.copy(
serverId = radarr.id,
profiles = profiles,
rootFolders = rootFolders,
)
}
}
if (radarr4k != null && radarr4k.id != null) {
val settings = api.serviceApi.serviceRadarrRadarrIdGet(radarr4k.id)
setProfilesAndFolder(
settings.profiles,
settings.rootFolders,
settings.server?.activeProfileId,
settings.server?.activeDirectory,
) { profiles, rootFolders ->
result =
result.copy(
server4kId = radarr4k.id,
profiles4k = profiles,
rootFolders4k = rootFolders,
)
}
}
} else {
val sonarrs = api.serviceApi.serviceSonarrGet()
val sonarr =
sonarrs.firstOrNull { it.isDefault && !it.is4k }
?: sonarrs.firstOrNull { !it.is4k }
val sonarr4k =
sonarrs.firstOrNull { it.isDefault && it.is4k }
?: sonarrs.firstOrNull { it.is4k }
if (sonarr != null && sonarr.id != null) {
val settings = api.serviceApi.serviceSonarrSonarrIdGet(sonarr.id)
setProfilesAndFolder(
settings.profiles,
settings.rootFolders,
settings.server?.activeProfileId,
settings.server?.activeDirectory,
) { profiles, rootFolders ->
result =
result.copy(
serverId = sonarr.id,
profiles = profiles,
rootFolders = rootFolders,
)
}
}
if (sonarr4k != null && sonarr4k.id != null) {
val settings = api.serviceApi.serviceSonarrSonarrIdGet(sonarr4k.id)
setProfilesAndFolder(
settings.profiles,
settings.rootFolders,
settings.server?.activeProfileId,
settings.server?.activeDirectory,
) { profiles, rootFolders ->
result =
result.copy(
server4kId = sonarr4k.id,
profiles4k = profiles,
rootFolders4k = rootFolders,
)
}
}
}
}
return result
}
private fun setProfilesAndFolder(
profiles: List<ServiceProfile>?,
rootFolders: List<RootFolder>?,
activeProfileId: Int?,
activeDirectory: String?,
setter: (List<SeerrProfile>, List<SeerrRootFolder>) -> Unit,
) {
val profiles =
profiles.orEmpty().mapNotNull {
if (it.id != null && it.name != null) {
SeerrProfile(it.id, it.name, activeProfileId == it.id)
} else {
null
}
}
val rootFolders =
rootFolders.orEmpty().mapNotNull {
if (it.id != null && it.path != null) {
val freeSpace = it.freeSpace?.let { formatBytes(it) } ?: ""
SeerrRootFolder(it.id, it.path, freeSpace, activeDirectory == it.path)
} else {
null
}
}
setter.invoke(profiles, rootFolders)
}
suspend fun createDiscoverItem(item: Any): DiscoverItem =
when (item) {
is MovieResult -> createDiscoverItem(item)

View file

@ -53,9 +53,6 @@ import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
@ -90,7 +87,7 @@ fun DiscoverMovieDetails(
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var showRequestDialog by remember { mutableStateOf(false) }
val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k)
@ -119,28 +116,8 @@ fun DiscoverMovieDetails(
similar = state.similar,
recommended = state.recommended,
requestOnClick = {
movie.id?.let { id ->
if (request4kEnabled) {
moreDialog =
DialogParams(
fromLongClick = false,
title = movie.title + " (${movie.releaseDate ?: ""})",
items =
listOf(
DialogItem(
text = requestStr,
onClick = { viewModel.request(id, false) },
),
DialogItem(
text = request4kStr,
onClick = { viewModel.request(id, true) },
),
),
)
} else {
viewModel.request(id, false)
}
}
viewModel.requestOnClick()
showRequestDialog = true
},
cancelOnClick = {
movie.id?.let { viewModel.cancelRequest(it) }
@ -170,14 +147,7 @@ fun DiscoverMovieDetails(
)
}
},
moreOnClick = {
moreDialog =
DialogParams(
fromLongClick = false,
title = movie.title + " (${movie.releaseDate ?: ""})",
items = listOf(),
)
},
moreOnClick = {},
onLongClickPerson = { index, person -> },
onLongClickSimilar = { index, similar ->
},
@ -186,6 +156,19 @@ fun DiscoverMovieDetails(
},
modifier = modifier,
)
if (showRequestDialog) {
RequestMovieDialog(
loading = state.profileLoading,
data = state.requestData,
request4kEnabled = request4kEnabled,
movie = movie,
onSubmit = {
viewModel.request(it)
showRequestDialog = false
},
onDismissRequest = { showRequestDialog = false },
)
}
}
}
overviewDialog?.let { info ->
@ -195,16 +178,6 @@ fun DiscoverMovieDetails(
onDismissRequest = { overviewDialog = null },
)
}
moreDialog?.let { params ->
DialogPopup(
showDialog = true,
title = params.title,
dialogItems = params.items,
onDismissRequest = { moreDialog = null },
dismissOnClick = true,
waitToLoad = params.fromLongClick,
)
}
}
private const val HEADER_ROW = 0

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.SeerrItemType
import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.hasPermission
@ -24,6 +25,7 @@ import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.successValue
import dagger.assisted.Assisted
@ -164,23 +166,33 @@ class DiscoverMovieViewModel
navigationManager.navigateTo(destination)
}
fun request(
id: Int,
is4k: Boolean,
) {
fun request(request: MovieRequest) {
viewModelScope.launchIO {
if (request.movieId == null) {
Timber.w("Null movie ID")
return@launchIO
}
try {
seerrService.api.requestApi.requestPost(
RequestPostRequest(
is4k = is4k,
mediaId = id,
is4k = request.is4k,
mediaId = request.movieId,
mediaType = RequestPostRequest.MediaType.MOVIE,
serverId =
when {
request.profileId == null && request.folder == null -> null
request.is4k -> request.data.server4kId
else -> request.data.serverId
},
profileId = request.profileId,
rootFolder = request.folder,
tags = emptyList(),
),
)
} catch (ex: kotlinx.coroutines.CancellationException) {
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error requesting %s", id)
Timber.e(ex, "Error requesting %s", request.movieId)
showToast(context, "An error occurred")
}
fetchAndSetItem().await()
@ -205,6 +217,28 @@ class DiscoverMovieViewModel
}
}
}
fun requestOnClick() {
viewModelScope.launchIO {
try {
val data = seerrService.getProfilesAndFolders(SeerrItemType.MOVIE)
_state.update {
it.copy(
profileLoading = LoadingState.Success,
requestData = data,
)
}
} catch (ex: Exception) {
Timber.e(ex, "Error getting profiles & folders")
showToast(context, "Error getting profiles & folders: ${ex.localizedMessage}")
_state.update {
it.copy(
profileLoading = LoadingState.Success,
)
}
}
}
}
}
fun canUserCancelRequest(
@ -226,4 +260,6 @@ data class DiscoverMovieState(
val similar: List<DiscoverItem> = emptyList(),
val recommended: List<DiscoverItem> = emptyList(),
val canCancelRequest: Boolean = false,
val profileLoading: LoadingState = LoadingState.Pending,
val requestData: SeerrRequestData = SeerrRequestData(),
)

View file

@ -92,9 +92,6 @@ fun DiscoverSeriesDetails(
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)
when (val st = state.tvSeries) {
is DataLoadingState.Error -> {
ErrorMessage(st, modifier)
@ -150,9 +147,8 @@ fun DiscoverSeriesDetails(
},
trailers = state.trailers,
requestOnClick = {
item.id?.let { id ->
showRequestSeasonDialog = true
}
viewModel.requestOnClick()
showRequestSeasonDialog = true
},
cancelOnClick = {
item.id?.let { viewModel.cancelRequest(it) }
@ -192,15 +188,16 @@ fun DiscoverSeriesDetails(
}
if (showRequestSeasonDialog) {
RequestSeasonsDialog(
id = state.tvSeries.successValue?.id ?: -1,
title = state.tvSeries.successValue?.name ?: "",
seasons = state.seasons,
request4kEnabled = request4kEnabled,
onSubmit = { seasons, is4k ->
state.tvSeries.successValue
?.id
?.let { viewModel.request(it, seasons, is4k) }
onSubmit = {
showRequestSeasonDialog = false
viewModel.request(it)
},
loading = state.profileLoading,
data = state.requestData,
onDismissRequest = { showRequestSeasonDialog = false },
)
}

View file

@ -12,6 +12,7 @@ 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.SeerrItemType
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
@ -22,6 +23,7 @@ import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.LoadingState
import com.github.damontecres.wholphin.util.WholphinDispatchers
import com.github.damontecres.wholphin.util.successValue
import dagger.assisted.Assisted
@ -198,11 +200,29 @@ class DiscoverSeriesViewModel
_state.update { it.copy(canCancelRequest = canCancel) }
}
fun request(
id: Int,
seasons: Set<Int>,
is4k: Boolean,
) {
fun requestOnClick() {
viewModelScope.launchIO {
try {
val data = seerrService.getProfilesAndFolders(SeerrItemType.TV)
_state.update {
it.copy(
profileLoading = LoadingState.Success,
requestData = data,
)
}
} catch (ex: Exception) {
Timber.e(ex, "Error getting profiles & folders")
showToast(context, "Error getting profiles & folders: ${ex.localizedMessage}")
_state.update {
it.copy(
profileLoading = LoadingState.Success,
)
}
}
}
}
fun request(request: TvRequest) {
viewModelScope.launchIO {
state.value.tvSeries.successValue?.let { tv ->
val currentRequest =
@ -217,26 +237,43 @@ class DiscoverSeriesViewModel
requestId = currentRequest.id.toString(),
requestRequestIdPutRequest =
RequestRequestIdPutRequest(
is4k = is4k,
is4k = request.is4k,
mediaType = RequestRequestIdPutRequest.MediaType.TV,
seasons = seasons.toList(),
seasons = request.seasons,
serverId =
when {
request.profileId == null && request.folder == null -> null
request.is4k -> request.data.server4kId
else -> request.data.serverId
},
profileId = request.profileId,
rootFolder = request.folder,
),
)
} else {
Timber.v("New request for %s seasons", seasons.size)
Timber.v("New request for %s seasons", request.seasons.size)
seerrService.api.requestApi.requestPost(
RequestPostRequest(
is4k = is4k,
mediaId = id,
is4k = request.is4k,
mediaId = request.tvId,
mediaType = RequestPostRequest.MediaType.TV,
seasons = seasons.toList(),
seasons = request.seasons,
serverId =
when {
request.profileId == null && request.folder == null -> null
request.is4k -> request.data.server4kId
else -> request.data.serverId
},
profileId = request.profileId,
rootFolder = request.folder,
tags = emptyList(),
),
)
}
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.e(ex, "Error requesting %s", id)
Timber.e(ex, "Error requesting %s", request.tvId)
showToast(context, "An error occurred")
}
@ -279,4 +316,6 @@ data class DiscoverSeriesState(
val similar: List<DiscoverItem> = emptyList(),
val recommended: List<DiscoverItem> = emptyList(),
val canCancelRequest: Boolean = false,
val profileLoading: LoadingState = LoadingState.Pending,
val requestData: SeerrRequestData = SeerrRequestData(),
)

View file

@ -0,0 +1,397 @@
package com.github.damontecres.wholphin.ui.detail.discover
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.DialogPopupContent
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
@Composable
fun RequestMovieDialog(
loading: LoadingState,
data: SeerrRequestData,
request4kEnabled: Boolean,
movie: MovieDetails,
onSubmit: (MovieRequest) -> Unit,
onDismissRequest: () -> Unit,
) {
BasicDialog(
onDismissRequest = onDismissRequest,
) {
RequestMovie(
loading = loading,
data = data,
request4kEnabled = request4kEnabled,
movie = movie,
onSubmit = onSubmit,
modifier = Modifier.padding(16.dp),
)
}
}
@Composable
fun RequestMovie(
loading: LoadingState,
data: SeerrRequestData,
request4kEnabled: Boolean,
movie: MovieDetails,
onSubmit: (MovieRequest) -> Unit,
modifier: Modifier = Modifier,
) {
when (loading) {
is LoadingState.Error -> {
ErrorMessage(loading, modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage(modifier)
}
LoadingState.Success -> {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k)
if (data.profiles.isEmpty() && data.rootFolders.isEmpty() &&
data.profiles4k.isEmpty() && data.rootFolders4k.isEmpty()
) {
if (request4kEnabled) {
val items =
remember {
listOf(
DialogItem(
text = requestStr,
onClick = {
onSubmit.invoke(MovieRequest(data, movie.id, false, null, null))
},
),
DialogItem(
text = request4kStr,
onClick = {
onSubmit.invoke(MovieRequest(data, movie.id, true, null, null))
},
),
)
}
DialogPopupContent(
title = movie.title + " (${movie.releaseDate ?: ""})",
dialogItems = items,
waiting = false,
onDismissRequest = {},
dismissOnClick = false,
modifier = modifier.focusRequester(focusRequester),
)
} else {
LaunchedEffect(Unit) {
onSubmit.invoke(MovieRequest(data, movie.id, false, null, null))
}
}
} else {
RequestMovieWithOptions(
data = data,
request4kEnabled = request4kEnabled,
movie = movie,
onSubmit = onSubmit,
modifier = modifier.focusRequester(focusRequester),
)
}
}
}
}
@Composable
private fun RequestMovieWithOptions(
data: SeerrRequestData,
request4kEnabled: Boolean,
movie: MovieDetails,
onSubmit: (MovieRequest) -> Unit,
modifier: Modifier = Modifier,
) {
var is4k by remember { mutableStateOf(request4kEnabled) }
var profile by remember(is4k) {
mutableStateOf(
if (is4k) {
data.profiles4k.firstOrNull { it.default } ?: data.profiles4k.firstOrNull()
} else {
data.profiles.firstOrNull { it.default } ?: data.profiles.firstOrNull()
},
)
}
var folder by remember(is4k) {
mutableStateOf(
if (is4k) {
data.rootFolders4k.firstOrNull { it.default } ?: data.rootFolders4k.firstOrNull()
} else {
data.rootFolders.firstOrNull { it.default } ?: data.rootFolders.firstOrNull()
},
)
}
val profiles = remember(is4k, data) { if (is4k) data.profiles4k else data.profiles }
val folders = remember(is4k, data) { if (is4k) data.rootFolders4k else data.rootFolders }
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = movie.title + " (${movie.releaseDate ?: ""})",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier,
) {
item {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
) {
if (request4kEnabled) {
ClickSwitch(
label = stringResource(R.string.request_4k),
checked = is4k,
onClick = { is4k = !is4k },
)
}
Button(
onClick = {
onSubmit.invoke(MovieRequest(data, movie.id, is4k, profile?.id, folder?.path))
},
) {
Text(
text = stringResource(R.string.submit),
)
}
}
}
if (profiles.isNotEmpty()) {
profile?.let {
item {
ChooseProfile(
selectedProfile = it,
profiles = profiles,
onClickProfile = { profile = it },
modifier = Modifier,
)
}
}
}
if (folders.isNotEmpty()) {
folder?.let {
item {
ChooseFolder(
selectedFolder = it,
folders = folders,
onClickFolder = { folder = it },
modifier = Modifier,
)
}
}
}
}
}
}
@Composable
fun ChooseProfile(
selectedProfile: SeerrProfile,
profiles: List<SeerrProfile>,
onClickProfile: (SeerrProfile) -> Unit,
modifier: Modifier = Modifier,
) {
val defaultStr = stringResource(R.string.is_default)
val qualityProfileStr = stringResource(R.string.quality_profile)
var showProfileDialog by remember { mutableStateOf(false) }
ListItem(
selected = false,
headlineContent = {
Text(qualityProfileStr)
},
supportingContent = {
val text =
remember(selectedProfile) {
if (selectedProfile.default) "${selectedProfile.name} ($defaultStr)" else selectedProfile.name
}
Text(text)
},
onClick = { showProfileDialog = true },
modifier = modifier,
)
if (showProfileDialog) {
val params =
remember {
val items =
profiles.map {
DialogItem(
headlineContent = {
Text(it.name)
},
supportingContent = {
if (it.default) {
Text(defaultStr)
}
},
leadingContent = {
SelectedLeadingContent(it == selectedProfile)
},
onClick = { onClickProfile.invoke(it) },
)
}
DialogParams(
fromLongClick = false,
title = qualityProfileStr,
items = items,
)
}
DialogPopup(
params = params,
onDismissRequest = { showProfileDialog = false },
dismissOnClick = true,
elevation = 3.dp,
)
}
}
@Composable
fun ChooseFolder(
selectedFolder: SeerrRootFolder,
folders: List<SeerrRootFolder>,
onClickFolder: (SeerrRootFolder) -> Unit,
modifier: Modifier = Modifier,
) {
val defaultStr = stringResource(R.string.is_default)
val rootFolderStr = stringResource(R.string.root_folder)
var showFolderDialog by remember { mutableStateOf(false) }
ListItem(
selected = false,
headlineContent = {
Text(rootFolderStr)
},
supportingContent = {
val text =
remember(selectedFolder) {
if (selectedFolder.default) "${selectedFolder.path} ($defaultStr)" else selectedFolder.path
}
Text(text)
},
trailingContent = {
Text(selectedFolder.freeSpace)
},
onClick = { showFolderDialog = true },
modifier = modifier,
)
if (showFolderDialog) {
val params =
remember {
val items =
folders.map {
DialogItem(
headlineContent = {
Text(it.path)
},
supportingContent = {
if (it.default) {
Text(defaultStr)
}
},
trailingContent = {
Text(it.freeSpace)
},
leadingContent = {
SelectedLeadingContent(it == selectedFolder)
},
onClick = { onClickFolder.invoke(it) },
)
}
DialogParams(
fromLongClick = false,
title = rootFolderStr,
items = items,
)
}
DialogPopup(
params = params,
onDismissRequest = { showFolderDialog = false },
dismissOnClick = true,
elevation = 3.dp,
)
}
}
@PreviewTvSpec
@Composable
fun MovieRequestPreview() {
WholphinTheme {
RequestMovie(
loading = LoadingState.Success,
data =
SeerrRequestData(
profiles =
listOf(
SeerrProfile(1, "HD", true),
SeerrProfile(2, "Ultra HD", false),
),
rootFolders =
listOf(
SeerrRootFolder(1, "/movies", "400GB", true),
),
profiles4k =
listOf(
SeerrProfile(1, "HD", true),
SeerrProfile(2, "Ultra HD", false),
),
rootFolders4k =
listOf(
SeerrRootFolder(1, "/movies", "400GB", true),
),
),
request4kEnabled = false,
movie = MovieDetails(id = 1, title = "Movie Name", releaseDate = "2026"),
onSubmit = {},
modifier =
Modifier
.width(320.dp)
.padding(16.dp),
)
}
}

View file

@ -12,7 +12,9 @@ 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.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateSetOf
@ -20,9 +22,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
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
@ -40,7 +43,11 @@ 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.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
data class RequestSeason(
val season: Season,
@ -49,14 +56,16 @@ data class RequestSeason(
@Composable
fun RequestSeasons(
id: Int,
title: String,
seasons: List<RequestSeason>,
onSubmit: (Set<Int>, Boolean) -> Unit,
data: SeerrRequestData,
request4kEnabled: Boolean,
modifier: Modifier,
onSubmit: (TvRequest) -> Unit,
modifier: Modifier = Modifier,
) {
val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() }
val selected =
val selectedSeasons =
remember {
mutableStateSetOf<Int>(
*seasons
@ -69,39 +78,74 @@ fun RequestSeasons(
}.toTypedArray(),
)
}
var is4k by remember { mutableStateOf(false) }
var is4k by remember { mutableStateOf(request4kEnabled) }
var profile by remember(is4k) {
mutableStateOf(
if (is4k) {
data.profiles4k.firstOrNull { it.default } ?: data.profiles4k.firstOrNull()
} else {
data.profiles.firstOrNull { it.default } ?: data.profiles.firstOrNull()
},
)
}
var folder by remember(is4k) {
mutableStateOf(
if (is4k) {
data.rootFolders4k.firstOrNull { it.default } ?: data.rootFolders4k.firstOrNull()
} else {
data.rootFolders.firstOrNull { it.default } ?: data.rootFolders.firstOrNull()
},
)
}
val profiles = remember(is4k, data) { if (is4k) data.profiles4k else data.profiles }
val folders = remember(is4k, data) { if (is4k) data.rootFolders4k else data.rootFolders }
fun submit() {
onSubmit.invoke(
TvRequest(
data = data,
tvId = id,
seasons = selectedSeasons.toList(),
is4k = is4k,
profileId = profile?.id,
folder = folder?.path,
),
)
}
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier,
)
LazyColumn {
LazyColumn(
modifier = Modifier,
) {
item {
val isSelected = selected.containsAll(allSeasonNumbers)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
ClickSwitch(
label = stringResource(R.string.select_all),
checked = isSelected,
onClick = {
if (isSelected) {
selected.removeAll(allSeasonNumbers)
} else {
selected.addAll(allSeasonNumbers)
}
},
)
if (request4kEnabled) {
ClickSwitch(
label = stringResource(R.string.request_4k),
checked = is4k,
onClick = { is4k = !is4k },
)
}
Button(
onClick = { onSubmit.invoke(selected, is4k) },
onClick = ::submit,
enabled = selectedSeasons.isNotEmpty(),
) {
Text(
text = stringResource(R.string.submit),
@ -109,39 +153,80 @@ fun RequestSeasons(
}
}
}
if (request4kEnabled) {
item {
ClickSwitch(
label = stringResource(R.string.request_4k),
checked = is4k,
onClick = { is4k = !is4k },
)
if (profiles.isNotEmpty()) {
profile?.let {
item {
ChooseProfile(
selectedProfile = it,
profiles = profiles,
onClickProfile = { profile = it },
modifier = Modifier,
)
}
}
}
if (folders.isNotEmpty()) {
folder?.let {
item {
ChooseFolder(
selectedFolder = it,
folders = folders,
onClickFolder = { folder = it },
modifier = Modifier,
)
}
}
}
item {
HorizontalDivider()
}
item {
Text(
text = stringResource(R.string.tv_seasons),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
val isSelected = selectedSeasons.containsAll(allSeasonNumbers)
ClickSwitch(
label = stringResource(R.string.select_all),
checked = isSelected,
onClick = {
if (isSelected) {
selectedSeasons.removeAll(allSeasonNumbers)
} else {
selectedSeasons.addAll(allSeasonNumbers)
}
},
)
}
itemsIndexed(seasons) { index, season ->
val seasonNumber = season.season.seasonNumber
val isSelected = seasonNumber in selected
val isSelected = seasonNumber in selectedSeasons
SeasonListItem(
season = season,
selected = isSelected,
onClick = {
if (isSelected) {
selected.remove(seasonNumber)
selectedSeasons.remove(seasonNumber)
} else {
seasonNumber?.let { selected.add(it) }
seasonNumber?.let { selectedSeasons.add(it) }
}
},
modifier = Modifier,
)
}
if (seasons.size > 7) {
if (seasons.size > 3) {
item {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Button(
onClick = { onSubmit.invoke(selected, is4k) },
onClick = ::submit,
enabled = selectedSeasons.isNotEmpty(),
) {
Text(
text = stringResource(R.string.submit),
@ -237,7 +322,7 @@ private fun ClickSurface(
}
@Composable
private fun ClickSwitch(
fun ClickSwitch(
label: String,
checked: Boolean,
onClick: () -> Unit,
@ -267,22 +352,46 @@ private fun ClickSwitch(
@Composable
fun RequestSeasonsDialog(
id: Int,
title: String,
loading: LoadingState,
data: SeerrRequestData,
seasons: List<RequestSeason>,
request4kEnabled: Boolean,
onSubmit: (Set<Int>, Boolean) -> Unit,
onSubmit: (TvRequest) -> Unit,
onDismissRequest: () -> Unit,
) {
BasicDialog(
onDismissRequest = onDismissRequest,
) {
RequestSeasons(
title = title,
seasons = seasons,
request4kEnabled = request4kEnabled,
onSubmit = onSubmit,
modifier = Modifier.padding(16.dp),
)
when (loading) {
is LoadingState.Error -> {
ErrorMessage(loading, Modifier)
}
LoadingState.Loading,
LoadingState.Pending,
-> {
LoadingPage(Modifier)
}
LoadingState.Success -> {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
RequestSeasons(
id = id,
title = title,
data = data,
seasons = seasons,
request4kEnabled = request4kEnabled,
onSubmit = onSubmit,
modifier =
Modifier
.padding(16.dp)
.focusRequester(focusRequester),
)
}
}
}
}
@ -313,10 +422,23 @@ fun RequestSeasonsPreview() {
WholphinTheme {
RequestSeasons(
id = 1,
title = "Series title",
seasons = seasons,
data =
SeerrRequestData(
profiles4k =
listOf(
SeerrProfile(1, "HD", true),
SeerrProfile(2, "Ultra HD", false),
),
rootFolders4k =
listOf(
SeerrRootFolder(1, "/tv", "400GB", true),
),
),
request4kEnabled = true,
onSubmit = { _, _ -> },
onSubmit = { },
modifier = Modifier.width(400.dp),
)
}

View file

@ -0,0 +1,40 @@
package com.github.damontecres.wholphin.ui.detail.discover
data class SeerrRequestData(
val serverId: Int = -1,
val server4kId: Int = -1,
val profiles: List<SeerrProfile> = emptyList(),
val rootFolders: List<SeerrRootFolder> = emptyList(),
val profiles4k: List<SeerrProfile> = emptyList(),
val rootFolders4k: List<SeerrRootFolder> = emptyList(),
)
data class SeerrProfile(
val id: Int,
val name: String,
val default: Boolean,
)
data class SeerrRootFolder(
val id: Int,
val path: String,
val freeSpace: String,
val default: Boolean,
)
data class MovieRequest(
val data: SeerrRequestData,
val movieId: Int?,
val is4k: Boolean,
val profileId: Int?,
val folder: String?,
)
data class TvRequest(
val data: SeerrRequestData,
val tvId: Int,
val seasons: List<Int>,
val is4k: Boolean,
val profileId: Int?,
val folder: String?,
)

View file

@ -821,4 +821,7 @@
<string name="tomatoes">Tomatoes</string>
<string name="sort">Sort</string>
<string name="split_into_separate_rows">Split into separate rows</string>
<string name="quality_profile">Quality profile</string>
<string name="is_default">Default</string>
<string name="root_folder">Root folder</string>
</resources>

View file

@ -603,15 +603,14 @@ components:
example: false
required:
- name
- hostname
- port
- apiKey
- useSsl
# - hostname
# - port
# - useSsl
- activeProfileId
- activeProfileName
# - activeProfileName
- activeDirectory
- is4k
- minimumAvailability
# - minimumAvailability
- isDefault
SonarrSettings:
type: object
@ -682,15 +681,15 @@ components:
example: false
required:
- name
- hostname
- port
- apiKey
- useSsl
# - hostname
# - port
# - apiKey
# - useSsl
- activeProfileId
- activeProfileName
# - activeProfileName
- activeDirectory
- is4k
- enableSeasonFolders
# - enableSeasonFolders
- isDefault
ServarrTag:
type: object
@ -1396,6 +1395,17 @@ components:
name:
type: string
example: 720p/1080p
RootFolder:
type: object
properties:
id:
type: integer
example: 1
freeSpace:
type: long
path:
type: string
example: /movies
PageInfo:
type: object
properties:
@ -6154,9 +6164,9 @@ paths:
is4k:
type: boolean
example: false
serverid:
serverId:
type: integer
profileid:
profileId:
type: integer
rootFolder:
type: string
@ -6166,6 +6176,10 @@ paths:
userid:
type: integer
nullable: true
tags:
type: array
items:
type: integer
required:
- mediaType
- mediaId
@ -6261,9 +6275,9 @@ paths:
is4k:
type: boolean
example: false
serverid:
serverId:
type: integer
profileid:
profileId:
type: integer
rootFolder:
type: string
@ -7061,7 +7075,13 @@ paths:
server:
$ref: '#/components/schemas/RadarrSettings'
profiles:
$ref: '#/components/schemas/ServiceProfile'
type: array
items:
$ref: '#/components/schemas/ServiceProfile'
rootFolders:
type: array
items:
$ref: '#/components/schemas/RootFolder'
/service/sonarr:
get:
summary: Get non-sensitive Sonarr server list
@ -7101,7 +7121,13 @@ paths:
server:
$ref: '#/components/schemas/SonarrSettings'
profiles:
$ref: '#/components/schemas/ServiceProfile'
type: array
items:
$ref: '#/components/schemas/ServiceProfile'
rootFolders:
type: array
items:
$ref: '#/components/schemas/RootFolder'
/service/sonarr/lookup/{tmdbId}:
get:
summary: Get series from Sonarr