Support requesting 4k media in discover (#685)

## Description
If the server support it and the user has permission, allows for
requesting 4K media

### Related issues
A follow up to #558
This commit is contained in:
Ray 2026-01-12 21:55:50 -05:00 committed by GitHub
parent 4c7c465c67
commit 80670c3e0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 138 additions and 33 deletions

View file

@ -7,12 +7,15 @@ import androidx.lifecycle.lifecycleScope
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest
import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest
import com.github.damontecres.wholphin.api.seerr.model.PublicSettings
import com.github.damontecres.wholphin.api.seerr.model.User import com.github.damontecres.wholphin.api.seerr.model.User
import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.data.model.SeerrUser
import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
@ -60,8 +63,9 @@ class SeerrServerRepository
user: SeerrUser, user: SeerrUser,
userConfig: SeerrUserConfig, userConfig: SeerrUserConfig,
) { ) {
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
_current.update { _current.update {
CurrentSeerr(server, user, userConfig) CurrentSeerr(server, user, userConfig, publicSettings)
} }
} }
@ -134,13 +138,8 @@ class SeerrServerRepository
passwordOrApiKey: String, passwordOrApiKey: String,
): LoadingState { ): LoadingState {
val api = SeerrApiClient(url, passwordOrApiKey, okHttpClient) val api = SeerrApiClient(url, passwordOrApiKey, okHttpClient)
try {
login(api, authMethod, username, passwordOrApiKey) login(api, authMethod, username, passwordOrApiKey)
return LoadingState.Success return LoadingState.Success
} catch (ex: Exception) {
Timber.w(ex, "Error testing seerr connection")
return LoadingState.Error(ex)
}
} }
suspend fun removeServer() { suspend fun removeServer() {
@ -159,7 +158,18 @@ data class CurrentSeerr(
val server: SeerrServer, val server: SeerrServer,
val user: SeerrUser, val user: SeerrUser,
val config: SeerrUserConfig, val config: SeerrUserConfig,
) val serverConfig: PublicSettings,
) {
val request4kMovieEnabled: Boolean
get() =
(serverConfig.movie4kEnabled ?: false) &&
config.hasPermission(SeerrPermission.REQUEST_4K_MOVIE)
val request4kTvEnabled: Boolean
get() =
(serverConfig.series4kEnabled ?: false) &&
config.hasPermission(SeerrPermission.REQUEST_4K_TV)
}
private suspend fun login( private suspend fun login(
client: SeerrApiClient, client: SeerrApiClient,

View file

@ -52,6 +52,7 @@ import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
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.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -94,10 +95,14 @@ fun DiscoverMovieDetails(
val recommended by viewModel.similar.observeAsState(listOf()) val recommended by viewModel.similar.observeAsState(listOf())
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val userConfig by viewModel.userConfig.collectAsState(null) val userConfig by viewModel.userConfig.collectAsState(null)
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 moreDialog by remember { mutableStateOf<DialogParams?>(null) }
val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k)
val moreActions = val moreActions =
MoreDialogActions( MoreDialogActions(
navigateTo = viewModel::navigateTo, navigateTo = viewModel::navigateTo,
@ -129,7 +134,28 @@ fun DiscoverMovieDetails(
similar = similar, similar = similar,
recommended = recommended, recommended = recommended,
requestOnClick = { requestOnClick = {
movie.id?.let { viewModel.request(it) } 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)
}
}
}, },
cancelOnClick = { cancelOnClick = {
movie.id?.let { viewModel.cancelRequest(it) } movie.id?.let { viewModel.cancelRequest(it) }

View file

@ -64,6 +64,7 @@ class DiscoverMovieViewModel
val recommended = MutableLiveData<List<DiscoverItem>>(listOf()) val recommended = MutableLiveData<List<DiscoverItem>>(listOf())
val userConfig = seerrServerRepository.current.map { it?.config } val userConfig = seerrServerRepository.current.map { it?.config }
val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
init { init {
init() init()
@ -145,12 +146,15 @@ class DiscoverMovieViewModel
navigationManager.navigateTo(destination) navigationManager.navigateTo(destination)
} }
fun request(id: Int) { fun request(
id: Int,
is4k: Boolean,
) {
viewModelScope.launchIO { viewModelScope.launchIO {
val request = val request =
seerrService.api.requestApi.requestPost( seerrService.api.requestApi.requestPost(
RequestPostRequest( RequestPostRequest(
is4k = false, is4k = is4k,
mediaId = id, mediaId = id,
mediaType = RequestPostRequest.MediaType.MOVIE, mediaType = RequestPostRequest.MediaType.MOVIE,
), ),

View file

@ -93,9 +93,14 @@ fun DiscoverSeriesDetails(
val similar by viewModel.similar.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf())
val recommended by viewModel.recommended.observeAsState(listOf()) val recommended by viewModel.recommended.observeAsState(listOf())
val userConfig by viewModel.userConfig.collectAsState(null) val userConfig by viewModel.userConfig.collectAsState(null)
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k)
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
@ -151,7 +156,28 @@ fun DiscoverSeriesDetails(
}, },
trailers = listOf(), trailers = listOf(),
requestOnClick = { requestOnClick = {
item.id?.let { viewModel.request(it) } item.id?.let { id ->
if (request4kEnabled) {
moreDialog =
DialogParams(
fromLongClick = false,
title = item.name ?: "",
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 = {
item.id?.let { viewModel.cancelRequest(it) } item.id?.let { viewModel.cancelRequest(it) }
@ -180,6 +206,16 @@ fun DiscoverSeriesDetails(
onDismissRequest = { seasonDialog = null }, onDismissRequest = { seasonDialog = 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

@ -63,6 +63,7 @@ class DiscoverSeriesViewModel
val recommended = MutableLiveData<List<DiscoverItem>>(listOf()) val recommended = MutableLiveData<List<DiscoverItem>>(listOf())
val userConfig = seerrServerRepository.current.map { it?.config } val userConfig = seerrServerRepository.current.map { it?.config }
val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
init { init {
init() init()
@ -136,12 +137,15 @@ class DiscoverSeriesViewModel
navigationManager.navigateTo(destination) navigationManager.navigateTo(destination)
} }
fun request(id: Int) { fun request(
id: Int,
is4k: Boolean,
) {
viewModelScope.launchIO { viewModelScope.launchIO {
val request = val request =
seerrService.api.requestApi.requestPost( seerrService.api.requestApi.requestPost(
RequestPostRequest( RequestPostRequest(
is4k = false, is4k = is4k,
mediaId = id, mediaId = id,
mediaType = RequestPostRequest.MediaType.TV, mediaType = RequestPostRequest.MediaType.TV,
seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons

View file

@ -466,6 +466,12 @@ fun PreferencesContent(
} }
} }
if (showSeerrServerDialog) { if (showSeerrServerDialog) {
val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending)
LaunchedEffect(status) {
if (status == LoadingState.Success) {
showSeerrServerDialog = false
}
}
if (seerrIntegrationEnabled) { if (seerrIntegrationEnabled) {
ConfirmDialog( ConfirmDialog(
title = stringResource(R.string.remove_seerr_server), title = stringResource(R.string.remove_seerr_server),
@ -478,12 +484,6 @@ fun PreferencesContent(
) )
} else { } else {
val currentUser by seerrVm.currentUser.observeAsState() val currentUser by seerrVm.currentUser.observeAsState()
val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending)
LaunchedEffect(status) {
if (status == LoadingState.Success) {
showSeerrServerDialog = false
}
}
AddSeerServerDialog( AddSeerServerDialog(
currentUsername = currentUser?.name, currentUsername = currentUser?.name,
status = status, status = status,

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.setup.seerr
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrServerRepository
@ -13,6 +14,7 @@ import jakarta.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import timber.log.Timber
@HiltViewModel @HiltViewModel
class SwitchSeerrViewModel class SwitchSeerrViewModel
@ -47,12 +49,21 @@ class SwitchSeerrViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
val url = cleanUrl(url) val url = cleanUrl(url)
val result = val result =
try {
seerrServerRepository.testConnection( seerrServerRepository.testConnection(
authMethod = SeerrAuthMethod.API_KEY, authMethod = SeerrAuthMethod.API_KEY,
url = url, url = url,
username = null, username = null,
passwordOrApiKey = apiKey, passwordOrApiKey = apiKey,
) )
} catch (ex: ClientException) {
Timber.w(ex, "Error logging in via API Key")
if (ex.statusCode == 401 || ex.statusCode == 403) {
LoadingState.Error("Invalid credentials", ex)
} else {
LoadingState.Error(ex)
}
}
if (result is LoadingState.Success) { if (result is LoadingState.Success) {
seerrServerRepository.addAndChangeServer(url, apiKey) seerrServerRepository.addAndChangeServer(url, apiKey)
} }
@ -69,12 +80,21 @@ class SwitchSeerrViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
val url = cleanUrl(url) val url = cleanUrl(url)
val result = val result =
try {
seerrServerRepository.testConnection( seerrServerRepository.testConnection(
authMethod = authMethod, authMethod = authMethod,
url = url, url = url,
username = username, username = username,
passwordOrApiKey = password, passwordOrApiKey = password,
) )
} catch (ex: ClientException) {
Timber.w(ex, "Error logging in via %s", authMethod)
if (ex.statusCode == 401 || ex.statusCode == 403) {
LoadingState.Error("Invalid credentials", ex)
} else {
LoadingState.Error(ex)
}
}
if (result is LoadingState.Success) { if (result is LoadingState.Success) {
seerrServerRepository.addAndChangeServer(url, authMethod, username, password) seerrServerRepository.addAndChangeServer(url, authMethod, username, password)
} }

View file

@ -440,6 +440,7 @@
<string name="trending">Trending</string> <string name="trending">Trending</string>
<string name="upcoming_movies">Upcoming Movies</string> <string name="upcoming_movies">Upcoming Movies</string>
<string name="upcoming_tv">Upcoming TV Shows</string> <string name="upcoming_tv">Upcoming TV Shows</string>
<string name="request_4k">Request in 4K</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>

View file

@ -707,6 +707,10 @@ components:
initialized: initialized:
type: boolean type: boolean
example: false example: false
movie4kEnabled:
type: boolean
series4kEnabled:
type: boolean
MovieResult: MovieResult:
type: object type: object
required: required: