From 77d7571276efcddb170881c3eef0171e2a880e43 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 22 Jun 2026 18:37:16 -0400 Subject: [PATCH] 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 request_movie Large ## AI or LLM usage None --- .../wholphin/services/SeerrService.kt | 128 ++++++ .../detail/discover/DiscoverMovieDetails.kt | 61 +-- .../detail/discover/DiscoverMovieViewModel.kt | 52 ++- .../detail/discover/DiscoverSeriesDetails.kt | 17 +- .../discover/DiscoverSeriesViewModel.kt | 63 ++- .../ui/detail/discover/RequestMovie.kt | 397 ++++++++++++++++++ .../ui/detail/discover/RequestSeasons.kt | 214 ++++++++-- .../ui/detail/discover/SeerrRequestData.kt | 40 ++ app/src/main/res/values/strings.xml | 3 + app/src/main/seerr/seerr-api.yml | 62 ++- 10 files changed, 899 insertions(+), 138 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/SeerrRequestData.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt index d9a340c9..9643da39 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt @@ -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?, + rootFolders: List?, + activeProfileId: Int?, + activeDirectory: String?, + setter: (List, List) -> 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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index bbc85042..4dea6d41 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -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(null) } - var moreDialog by remember { mutableStateOf(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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt index 14440539..9afde46e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.RemoteTrailer +import com.github.damontecres.wholphin.data.model.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 = emptyList(), val recommended: List = emptyList(), val canCancelRequest: Boolean = false, + val profileLoading: LoadingState = LoadingState.Pending, + val requestData: SeerrRequestData = SeerrRequestData(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index 5b87b3cd..be9acc64 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -92,9 +92,6 @@ fun DiscoverSeriesDetails( var moreDialog by remember { mutableStateOf(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 }, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt index 96ba1efa..4a9eb3f2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -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, - 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 = emptyList(), val recommended: List = emptyList(), val canCancelRequest: Boolean = false, + val profileLoading: LoadingState = LoadingState.Pending, + val requestData: SeerrRequestData = SeerrRequestData(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt new file mode 100644 index 00000000..7a009f09 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt @@ -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, + 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, + 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), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt index 4a56cb88..04ce859b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt @@ -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, - onSubmit: (Set, 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( *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, request4kEnabled: Boolean, - onSubmit: (Set, 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), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/SeerrRequestData.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/SeerrRequestData.kt new file mode 100644 index 00000000..355049dc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/SeerrRequestData.kt @@ -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 = emptyList(), + val rootFolders: List = emptyList(), + val profiles4k: List = emptyList(), + val rootFolders4k: List = 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, + val is4k: Boolean, + val profileId: Int?, + val folder: String?, +) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5240223b..facdec73 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -821,4 +821,7 @@ Tomatoes Sort Split into separate rows + Quality profile + Default + Root folder diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index 4c21b699..254bdb09 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -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