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.MediaInfo
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails 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.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.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.TvDetails
import com.github.damontecres.wholphin.api.seerr.model.TvResult import com.github.damontecres.wholphin.api.seerr.model.TvResult
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
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.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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.toLocalDate import com.github.damontecres.wholphin.ui.toLocalDate
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -191,6 +199,126 @@ class SeerrService
return "${base}${prefix}$path" 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 = suspend fun createDiscoverItem(item: Any): DiscoverItem =
when (item) { when (item) {
is MovieResult -> createDiscoverItem(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.DiscoverPersonRow
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard 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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
@ -90,7 +87,7 @@ fun DiscoverMovieDetails(
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } 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 requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k) val request4kStr = stringResource(R.string.request_4k)
@ -119,28 +116,8 @@ fun DiscoverMovieDetails(
similar = state.similar, similar = state.similar,
recommended = state.recommended, recommended = state.recommended,
requestOnClick = { requestOnClick = {
movie.id?.let { id -> viewModel.requestOnClick()
if (request4kEnabled) { showRequestDialog = true
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)
}
}
}, },
cancelOnClick = { cancelOnClick = {
movie.id?.let { viewModel.cancelRequest(it) } movie.id?.let { viewModel.cancelRequest(it) }
@ -170,14 +147,7 @@ fun DiscoverMovieDetails(
) )
} }
}, },
moreOnClick = { moreOnClick = {},
moreDialog =
DialogParams(
fromLongClick = false,
title = movie.title + " (${movie.releaseDate ?: ""})",
items = listOf(),
)
},
onLongClickPerson = { index, person -> }, onLongClickPerson = { index, person -> },
onLongClickSimilar = { index, similar -> onLongClickSimilar = { index, similar ->
}, },
@ -186,6 +156,19 @@ fun DiscoverMovieDetails(
}, },
modifier = modifier, 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 -> overviewDialog?.let { info ->
@ -195,16 +178,6 @@ fun DiscoverMovieDetails(
onDismissRequest = { overviewDialog = null }, 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 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.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.SeerrItemType
import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.hasPermission 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.nav.Destination
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.DataLoadingState 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.WholphinDispatchers
import com.github.damontecres.wholphin.util.successValue import com.github.damontecres.wholphin.util.successValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -164,23 +166,33 @@ class DiscoverMovieViewModel
navigationManager.navigateTo(destination) navigationManager.navigateTo(destination)
} }
fun request( fun request(request: MovieRequest) {
id: Int,
is4k: Boolean,
) {
viewModelScope.launchIO { viewModelScope.launchIO {
if (request.movieId == null) {
Timber.w("Null movie ID")
return@launchIO
}
try { try {
seerrService.api.requestApi.requestPost( seerrService.api.requestApi.requestPost(
RequestPostRequest( RequestPostRequest(
is4k = is4k, is4k = request.is4k,
mediaId = id, mediaId = request.movieId,
mediaType = RequestPostRequest.MediaType.MOVIE, 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 throw ex
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error requesting %s", id) Timber.e(ex, "Error requesting %s", request.movieId)
showToast(context, "An error occurred") showToast(context, "An error occurred")
} }
fetchAndSetItem().await() 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( fun canUserCancelRequest(
@ -226,4 +260,6 @@ data class DiscoverMovieState(
val similar: List<DiscoverItem> = emptyList(), val similar: List<DiscoverItem> = emptyList(),
val recommended: List<DiscoverItem> = emptyList(), val recommended: List<DiscoverItem> = emptyList(),
val canCancelRequest: Boolean = false, 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 moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var showRequestSeasonDialog by remember { mutableStateOf(false) } var showRequestSeasonDialog by remember { mutableStateOf(false) }
val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k)
when (val st = state.tvSeries) { when (val st = state.tvSeries) {
is DataLoadingState.Error -> { is DataLoadingState.Error -> {
ErrorMessage(st, modifier) ErrorMessage(st, modifier)
@ -150,9 +147,8 @@ fun DiscoverSeriesDetails(
}, },
trailers = state.trailers, trailers = state.trailers,
requestOnClick = { requestOnClick = {
item.id?.let { id -> viewModel.requestOnClick()
showRequestSeasonDialog = true showRequestSeasonDialog = true
}
}, },
cancelOnClick = { cancelOnClick = {
item.id?.let { viewModel.cancelRequest(it) } item.id?.let { viewModel.cancelRequest(it) }
@ -192,15 +188,16 @@ fun DiscoverSeriesDetails(
} }
if (showRequestSeasonDialog) { if (showRequestSeasonDialog) {
RequestSeasonsDialog( RequestSeasonsDialog(
id = state.tvSeries.successValue?.id ?: -1,
title = state.tvSeries.successValue?.name ?: "", title = state.tvSeries.successValue?.name ?: "",
seasons = state.seasons, seasons = state.seasons,
request4kEnabled = request4kEnabled, request4kEnabled = request4kEnabled,
onSubmit = { seasons, is4k -> onSubmit = {
state.tvSeries.successValue
?.id
?.let { viewModel.request(it, seasons, is4k) }
showRequestSeasonDialog = false showRequestSeasonDialog = false
viewModel.request(it)
}, },
loading = state.profileLoading,
data = state.requestData,
onDismissRequest = { showRequestSeasonDialog = false }, 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.DiscoverRating
import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.RemoteTrailer
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.Trailer import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager 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.nav.Destination
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.DataLoadingState 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.WholphinDispatchers
import com.github.damontecres.wholphin.util.successValue import com.github.damontecres.wholphin.util.successValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -198,11 +200,29 @@ class DiscoverSeriesViewModel
_state.update { it.copy(canCancelRequest = canCancel) } _state.update { it.copy(canCancelRequest = canCancel) }
} }
fun request( fun requestOnClick() {
id: Int, viewModelScope.launchIO {
seasons: Set<Int>, try {
is4k: Boolean, 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 { viewModelScope.launchIO {
state.value.tvSeries.successValue?.let { tv -> state.value.tvSeries.successValue?.let { tv ->
val currentRequest = val currentRequest =
@ -217,26 +237,43 @@ class DiscoverSeriesViewModel
requestId = currentRequest.id.toString(), requestId = currentRequest.id.toString(),
requestRequestIdPutRequest = requestRequestIdPutRequest =
RequestRequestIdPutRequest( RequestRequestIdPutRequest(
is4k = is4k, is4k = request.is4k,
mediaType = RequestRequestIdPutRequest.MediaType.TV, 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 { } else {
Timber.v("New request for %s seasons", seasons.size) Timber.v("New request for %s seasons", request.seasons.size)
seerrService.api.requestApi.requestPost( seerrService.api.requestApi.requestPost(
RequestPostRequest( RequestPostRequest(
is4k = is4k, is4k = request.is4k,
mediaId = id, mediaId = request.tvId,
mediaType = RequestPostRequest.MediaType.TV, 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) { } catch (ex: CancellationException) {
throw ex throw ex
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error requesting %s", id) Timber.e(ex, "Error requesting %s", request.tvId)
showToast(context, "An error occurred") showToast(context, "An error occurred")
} }
@ -279,4 +316,6 @@ data class DiscoverSeriesState(
val similar: List<DiscoverItem> = emptyList(), val similar: List<DiscoverItem> = emptyList(),
val recommended: List<DiscoverItem> = emptyList(), val recommended: List<DiscoverItem> = emptyList(),
val canCancelRequest: Boolean = false, 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.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.mutableStateSetOf
@ -20,9 +22,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button 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.PartiallyAvailableIndicator
import com.github.damontecres.wholphin.ui.cards.PendingIndicator import com.github.damontecres.wholphin.ui.cards.PendingIndicator
import com.github.damontecres.wholphin.ui.components.BasicDialog 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.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
data class RequestSeason( data class RequestSeason(
val season: Season, val season: Season,
@ -49,14 +56,16 @@ data class RequestSeason(
@Composable @Composable
fun RequestSeasons( fun RequestSeasons(
id: Int,
title: String, title: String,
seasons: List<RequestSeason>, seasons: List<RequestSeason>,
onSubmit: (Set<Int>, Boolean) -> Unit, data: SeerrRequestData,
request4kEnabled: Boolean, request4kEnabled: Boolean,
modifier: Modifier, onSubmit: (TvRequest) -> Unit,
modifier: Modifier = Modifier,
) { ) {
val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() } val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() }
val selected = val selectedSeasons =
remember { remember {
mutableStateSetOf<Int>( mutableStateSetOf<Int>(
*seasons *seasons
@ -69,39 +78,74 @@ fun RequestSeasons(
}.toTypedArray(), }.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( Column(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier, modifier = modifier,
) { ) {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, modifier = Modifier,
modifier = Modifier.fillMaxWidth(),
) )
LazyColumn { LazyColumn(
modifier = Modifier,
) {
item { item {
val isSelected = selected.containsAll(allSeasonNumbers)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(), modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) { ) {
ClickSwitch( if (request4kEnabled) {
label = stringResource(R.string.select_all), ClickSwitch(
checked = isSelected, label = stringResource(R.string.request_4k),
onClick = { checked = is4k,
if (isSelected) { onClick = { is4k = !is4k },
selected.removeAll(allSeasonNumbers) )
} else { }
selected.addAll(allSeasonNumbers)
}
},
)
Button( Button(
onClick = { onSubmit.invoke(selected, is4k) }, onClick = ::submit,
enabled = selectedSeasons.isNotEmpty(),
) { ) {
Text( Text(
text = stringResource(R.string.submit), text = stringResource(R.string.submit),
@ -109,39 +153,80 @@ fun RequestSeasons(
} }
} }
} }
if (request4kEnabled) { if (profiles.isNotEmpty()) {
item { profile?.let {
ClickSwitch( item {
label = stringResource(R.string.request_4k), ChooseProfile(
checked = is4k, selectedProfile = it,
onClick = { is4k = !is4k }, 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 -> itemsIndexed(seasons) { index, season ->
val seasonNumber = season.season.seasonNumber val seasonNumber = season.season.seasonNumber
val isSelected = seasonNumber in selected val isSelected = seasonNumber in selectedSeasons
SeasonListItem( SeasonListItem(
season = season, season = season,
selected = isSelected, selected = isSelected,
onClick = { onClick = {
if (isSelected) { if (isSelected) {
selected.remove(seasonNumber) selectedSeasons.remove(seasonNumber)
} else { } else {
seasonNumber?.let { selected.add(it) } seasonNumber?.let { selectedSeasons.add(it) }
} }
}, },
modifier = Modifier, modifier = Modifier,
) )
} }
if (seasons.size > 7) { if (seasons.size > 3) {
item { item {
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) { ) {
Button( Button(
onClick = { onSubmit.invoke(selected, is4k) }, onClick = ::submit,
enabled = selectedSeasons.isNotEmpty(),
) { ) {
Text( Text(
text = stringResource(R.string.submit), text = stringResource(R.string.submit),
@ -237,7 +322,7 @@ private fun ClickSurface(
} }
@Composable @Composable
private fun ClickSwitch( fun ClickSwitch(
label: String, label: String,
checked: Boolean, checked: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
@ -267,22 +352,46 @@ private fun ClickSwitch(
@Composable @Composable
fun RequestSeasonsDialog( fun RequestSeasonsDialog(
id: Int,
title: String, title: String,
loading: LoadingState,
data: SeerrRequestData,
seasons: List<RequestSeason>, seasons: List<RequestSeason>,
request4kEnabled: Boolean, request4kEnabled: Boolean,
onSubmit: (Set<Int>, Boolean) -> Unit, onSubmit: (TvRequest) -> Unit,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
BasicDialog( BasicDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
) { ) {
RequestSeasons( when (loading) {
title = title, is LoadingState.Error -> {
seasons = seasons, ErrorMessage(loading, Modifier)
request4kEnabled = request4kEnabled, }
onSubmit = onSubmit,
modifier = Modifier.padding(16.dp), 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 { WholphinTheme {
RequestSeasons( RequestSeasons(
id = 1,
title = "Series title", title = "Series title",
seasons = seasons, seasons = seasons,
data =
SeerrRequestData(
profiles4k =
listOf(
SeerrProfile(1, "HD", true),
SeerrProfile(2, "Ultra HD", false),
),
rootFolders4k =
listOf(
SeerrRootFolder(1, "/tv", "400GB", true),
),
),
request4kEnabled = true, request4kEnabled = true,
onSubmit = { _, _ -> }, onSubmit = { },
modifier = Modifier.width(400.dp), 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="tomatoes">Tomatoes</string>
<string name="sort">Sort</string> <string name="sort">Sort</string>
<string name="split_into_separate_rows">Split into separate rows</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> </resources>

View file

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