From f66746cf471778bb48a718b6338c6e90c4f85dd6 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 26 Oct 2026 14:18:56 -0400 Subject: [PATCH] Auto-configure Discover from Wholphin server plugin (#2134) ## Description https://github.com/damontecres/jellyfin-plugin-wholphin/pull/3 added a new endpoint for Seerr configuration in Wholphin server plugin. This PR adds support for reading that configuration and either pre-filling the URL or automatically logging in. Some code was copied from #1418 (thanks @matschi95!), but that PR worked by inserting a DB record for the plugin provided server. Since the local DB was preferred, this meant plugin config changes wouldn't be reflected in the client. This PR instead attempts the connection and only updates the in-memory connection state, so user switching and app restarts will re-fetch from the plugin. If the server plugin is not installed or no Seerr server is configured, nothing in Wholphin changes. ### Related issues #598 This PR replaces #1418 ### Testing Emulator with different scenarios ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/SeerrPluginConfig.kt | 36 +++++ .../services/SeerrServerRepository.kt | 110 ++++++++++++-- .../wholphin/services/ServerPluginApi.kt | 44 +++--- .../wholphin/services/UserSwitchListener.kt | 94 +++++++----- .../wholphin/ui/components/Dialogs.kt | 10 +- .../ui/preferences/PreferenceUtils.kt | 8 +- .../ui/preferences/PreferencesContent.kt | 88 ++---------- .../wholphin/ui/setup/seerr/AddSeerrServer.kt | 25 +++- .../ui/setup/seerr/AddSeerrServerDialog.kt | 136 ++++++++++++++++++ .../ui/setup/seerr/SwitchSeerrViewModel.kt | 61 +++++++- app/src/main/res/values/strings.xml | 2 + 11 files changed, 454 insertions(+), 160 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt new file mode 100644 index 00000000..1ff7212b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt @@ -0,0 +1,36 @@ +package com.github.damontecres.wholphin.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed interface SeerrPluginLogin { + @SerialName("None") + @Serializable + data object None : SeerrPluginLogin + + @SerialName("ApiKey") + @Serializable + data class ApiKey( + val apiKey: String, + ) : SeerrPluginLogin + + @SerialName("Local") + @Serializable + data class Local( + val local: SeerrPluginLocalLogin, + ) : SeerrPluginLogin +} + +@Serializable +data class SeerrPluginLocalLogin( + val username: String, + val password: String, +) + +@Serializable +data class SeerrPluginSettings( + val version: Int = 1, + val serverUrl: String? = null, + val login: SeerrPluginLogin? = null, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index ea170950..0d0eb0b6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -9,20 +9,24 @@ import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.SeerrPluginLogin +import com.github.damontecres.wholphin.data.model.SeerrPluginSettings import com.github.damontecres.wholphin.data.model.SeerrServer 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.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import okhttp3.OkHttpClient import org.jellyfin.sdk.model.api.ImageType +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.seconds @@ -39,26 +43,25 @@ class SeerrServerRepository private val serverRepository: ServerRepository, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { - private val _connection = - MutableStateFlow(SeerrConnectionStatus.NotConfigured) - val connection: StateFlow = _connection + private val _connection = MutableStateFlow(SeerrState()) + val connection: StateFlow = _connection val current: Flow = - _connection.map { (it as? SeerrConnectionStatus.Success)?.current } + _connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current } val currentServer: Flow = - connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server } + connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.server } val currentUser: Flow = - connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } + connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.user } val currentUserId: Flow = current.map { it?.config?.id } /** * Whether Seerr integration is currently active of not */ val active: Flow = - connection.map { it is SeerrConnectionStatus.Success && seerrApi.active } + connection.map { it.connection is SeerrConnectionStatus.Success && seerrApi.active } fun clear() { - _connection.update { SeerrConnectionStatus.NotConfigured } + _connection.update { SeerrState() } seerrApi.update("", null) } @@ -67,7 +70,7 @@ class SeerrServerRepository user: SeerrUser, exception: Exception, ) { - _connection.update { SeerrConnectionStatus.Error(server, user, exception) } + _connection.update { it.copy(connection = SeerrConnectionStatus.Error(server, user, exception)) } seerrApi.update("", null) } @@ -78,8 +81,9 @@ class SeerrServerRepository ) { val publicSettings = seerrApi.api.settingsApi.settingsPublicGet() _connection.update { - SeerrConnectionStatus.Success( - CurrentSeerr(server, user, userConfig, publicSettings), + it.copy( + connection = + SeerrConnectionStatus.Success(CurrentSeerr(server, user, userConfig, publicSettings)), ) } } @@ -169,7 +173,7 @@ class SeerrServerRepository suspend fun removeServerForCurrentUser(): Boolean { val user = - when (val conn = connection.first()) { + when (val conn = connection.value.connection) { SeerrConnectionStatus.NotConfigured -> return false is SeerrConnectionStatus.Error -> conn.user is SeerrConnectionStatus.Success -> conn.current.user @@ -178,6 +182,81 @@ class SeerrServerRepository clear() return rows > 0 } + + /** + * Given the [SeerrPluginSettings], try to connect to the Seerr server + * + * @return true if successfully updated the SeerrApi and logged in + */ + suspend fun usePluginProvidedServer(settings: SeerrPluginSettings): Boolean { + val currentUser = serverRepository.currentUser ?: return false + if (settings.serverUrl.isNotNullOrBlank() && settings.login != null && settings.login != SeerrPluginLogin.None) { + val server = + SeerrServer( + id = -1, + url = settings.serverUrl, + ) + val user = + SeerrUser( + jellyfinUserRowId = currentUser.rowId, + serverId = server.id, + authMethod = + when (settings.login) { + is SeerrPluginLogin.ApiKey -> SeerrAuthMethod.API_KEY + is SeerrPluginLogin.Local -> SeerrAuthMethod.LOCAL + }, + username = + when (settings.login) { + is SeerrPluginLogin.ApiKey -> null + is SeerrPluginLogin.Local -> settings.login.local.username + }, + password = null, + credential = null, + ) + try { + val userConfig = + when (settings.login) { + is SeerrPluginLogin.ApiKey -> { + Timber.v("maybeAddPluginServer, using API key") + seerrApi.update(settings.serverUrl, settings.login.apiKey) + seerrLogin(seerrApi.api, SeerrAuthMethod.API_KEY, null, null) + } + + is SeerrPluginLogin.Local -> { + Timber.v("maybeAddPluginServer, using local user") + seerrApi.update(settings.serverUrl, null) + seerrLogin( + seerrApi.api, + SeerrAuthMethod.LOCAL, + settings.login.local.username, + settings.login.local.password, + ) + } + + SeerrPluginLogin.None -> { + return false + } + } + // Success + _connection.update { it.copy(pluginProvided = true) } + set(server, user, userConfig) + return true + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + clear() + Timber.w(ex, "Error adding plugin provided seerr") + _connection.update { + it.copy( + pluginProvided = true, + connection = SeerrConnectionStatus.Error(server, user, ex), + ) + } + return false + } + } + return false + } } /** @@ -185,6 +264,11 @@ class SeerrServerRepository */ typealias SeerrUserConfig = User +data class SeerrState( + val connection: SeerrConnectionStatus = SeerrConnectionStatus.NotConfigured, + val pluginProvided: Boolean = false, +) + sealed interface SeerrConnectionStatus { data object NotConfigured : SeerrConnectionStatus diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt index 896b483c..8b51ad63 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt @@ -1,11 +1,13 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.data.model.HomePageSettings +import com.github.damontecres.wholphin.data.model.SeerrPluginSettings import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.decodeFromStream +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import okio.IOException @@ -32,6 +34,7 @@ class ServerPluginApi companion object { private const val HOME_CONFIG_PATH = "homesettings" + private const val SEERR_CONFIG_PATH = "seerrsettings" } /** @@ -57,32 +60,37 @@ class ServerPluginApi } } - /** - * Get the default home page settings from the plugin - * - * @return the home page settings or null if no rows are configured or the server isn't installed - */ + private suspend inline fun executeRequest(path: String): T? { + val url = createUrl(path) ?: return null + val request = Request(url.toHttpUrl()) + return executeRequest(request) + } + @OptIn(ExperimentalSerializationApi::class) - suspend fun fetchHomePageSettings(): HomePageSettings? = + private suspend inline fun executeRequest(request: Request): T? = withContext(WholphinDispatchers.IO) { - val url = createUrl(HOME_CONFIG_PATH) ?: return@withContext null - val request = - Request - .Builder() - .url(url) - .get() - .build() - return@withContext okHttpClient.newCall(request).execute().use { res -> + okHttpClient.newCall(request).execute().use { res -> if (res.isSuccessful) { - json - .decodeFromStream(res.body.byteStream()) - .takeIf { it.rows.isNotEmpty() } + json.decodeFromStream(res.body.byteStream()) } else if (res.code == 404) { - Timber.w("fetchHomePageSettings returned 404") + Timber.w("Plugin 404: %s", request.url.encodedPath) null } else { throw ApiClientException(res.code.toString() + " " + res.body.string()) } } } + + /** + * Get the default home page settings from the plugin + * + * @return the home page settings or null if no rows are configured or the server isn't installed + */ + suspend fun fetchHomePageSettings(): HomePageSettings? = + executeRequest(HOME_CONFIG_PATH)?.takeIf { it.rows.isNotEmpty() } + + /** + * Get the [SeerrPluginSettings] as configured by teh server plugin, or null if none are configured + */ + suspend fun fetchSeerrSettings(): SeerrPluginSettings? = executeRequest(SEERR_CONFIG_PATH) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt index b64b15ca..b208d663 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt @@ -67,7 +67,7 @@ class UserSwitchListener AppCompatDelegate.setApplicationLocales(localeList) } - // Check if plugin is installed, then for home settings + // Check if plugin is installed, then for home settings & seerr settings launchIO { val serverPluginInstalled = try { @@ -81,48 +81,64 @@ class UserSwitchListener Timber.i("Server plugin installed: %s", serverPluginInstalled) serverRepository.serverPluginInstalled.value = serverPluginInstalled - // Check for home settings - homeSettingsService.loadCurrentSettings(user) - } - if (BuildConfig.DISCOVER_ENABLED) { - // Check for seerr server launchIO { - seerrServerDao - .getUsersByJellyfinUser(user.rowId) - .lastOrNull() - ?.let { seerrUser -> - val server = - seerrServerDao.getServer(seerrUser.serverId)?.server - if (server != null) { - Timber.i("Found a seerr user & server") - try { - seerrApi.update(server.url, seerrUser.credential) - val userConfig = - if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { - seerrLogin( - seerrApi.api, - seerrUser.authMethod, - seerrUser.username, - seerrUser.password, - ) - } else { - seerrApi.api.usersApi.authMeGet() - } - seerrServerRepository.set( - server, - seerrUser, - userConfig, - ) - } catch (ex: Exception) { - Timber.w( - ex, - "Error logging into %s", - server.url, - ) - seerrServerRepository.error(server, seerrUser, ex) + // Check for home settings + homeSettingsService.loadCurrentSettings(user) + } + + if (BuildConfig.DISCOVER_ENABLED) { + // Check for seerr server + launchIO { + if (serverPluginInstalled) { + serverPluginApi.fetchSeerrSettings()?.let { + Timber.d("Trying seerr auto login") + val result = seerrServerRepository.usePluginProvidedServer(it) + if (result) { + // Successfully used server plugin provided seerr + Timber.i("Seerr auto login succeeded") + return@launchIO } } } + Timber.v("Checking for existing configured seerr server") + // Either no server plugin seerr or there was an issue with it + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .lastOrNull() + ?.let { seerrUser -> + val server = + seerrServerDao.getServer(seerrUser.serverId)?.server + if (server != null) { + Timber.i("Found a seerr user & server") + try { + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { + seerrLogin( + seerrApi.api, + seerrUser.authMethod, + seerrUser.username, + seerrUser.password, + ) + } else { + seerrApi.api.usersApi.authMeGet() + } + seerrServerRepository.set( + server, + seerrUser, + userConfig, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.error(server, seerrUser, ex) + } + } + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 2e60f675..76b7dc77 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -450,12 +450,14 @@ fun ConfirmDialog( properties: DialogProperties = DialogProperties(), elevation: Dp = 1.dp, bodyColor: Color = MaterialTheme.colorScheme.onSurface, + @StringRes cancelStringRes: Int = R.string.cancel, + @StringRes confirmStringRes: Int = R.string.confirm, ) = BasicDialog( onDismissRequest = onCancel, properties = properties, elevation = elevation, content = { - ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor) + ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor, cancelStringRes, confirmStringRes) }, ) @@ -470,6 +472,8 @@ fun ConfirmDialogContent( onConfirm: () -> Unit, modifier: Modifier = Modifier, bodyColor: Color = MaterialTheme.colorScheme.onSurface, + @StringRes cancelStringRes: Int = R.string.cancel, + @StringRes confirmStringRes: Int = R.string.confirm, ) { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -500,11 +504,11 @@ fun ConfirmDialogContent( modifier = Modifier.fillMaxWidth(), ) { TextButton( - stringRes = R.string.cancel, + stringRes = cancelStringRes, onClick = onCancel, ) TextButton( - stringRes = R.string.confirm, + stringRes = confirmStringRes, onClick = onConfirm, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt index ad7b7afc..81f4833b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt @@ -57,6 +57,7 @@ enum class PreferenceScreenOption { fun rememberSeerrDialogBody( user: SeerrUser, server: SeerrServer, + pluginProvided: Boolean, ex: Exception? = null, ): String { val username = @@ -81,8 +82,13 @@ fun rememberSeerrDialogBody( ${stringResource(R.string.url)}: ${server.url} ${stringResource(R.string.username)}: $username$method """.trimIndent() + + if (pluginProvided) { + "\n\n${stringResource(R.string.managed_by_server)}" + } else { + "" + } + if (ex != null) { - "\n\n${stringResource(R.string.voice_error_server)}: ${ex.localizedMessage}" + "\n\n${stringResource(R.string.error)}: ${ex.localizedMessage}" } else { "" } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index aeb6ba22..e30484a4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -48,8 +48,6 @@ import androidx.tv.material3.surfaceColorAtElevation import coil3.SingletonImageLoader import coil3.imageLoader import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.SeerrServer -import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppSwitchPreference @@ -66,7 +64,6 @@ import com.github.damontecres.wholphin.preferences.experimentalPreferences import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling import com.github.damontecres.wholphin.preferences.screensaverPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences -import com.github.damontecres.wholphin.services.CurrentSeerr import com.github.damontecres.wholphin.services.Release import com.github.damontecres.wholphin.services.SeerrConnectionStatus import com.github.damontecres.wholphin.services.UpdateChecker @@ -83,7 +80,8 @@ import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.ui.serverSupportedMessage import com.github.damontecres.wholphin.ui.setup.ReleaseNotes import com.github.damontecres.wholphin.ui.setup.UpdateViewModel -import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog +import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialog +import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialogMode import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -501,7 +499,7 @@ fun PreferencesContent( title = stringResource(pref.title), onClick = { seerrDialogMode = - when (val conn = seerrConnection) { + when (val conn = seerrConnection.connection) { is SeerrConnectionStatus.Error -> { SeerrDialogMode.Error( conn.server, @@ -523,15 +521,13 @@ fun PreferencesContent( }, modifier = focusModifier, summary = - when (seerrConnection) { + when (seerrConnection.connection) { is SeerrConnectionStatus.Error -> { stringResource(R.string.voice_error_server) } SeerrConnectionStatus.NotConfigured -> { - stringResource( - R.string.add_server, - ) + stringResource(R.string.add_server) } is SeerrConnectionStatus.Success -> { @@ -726,59 +722,15 @@ fun PreferencesContent( ) } } - when (val mode = seerrDialogMode) { - is SeerrDialogMode.Remove -> { - val body = rememberSeerrDialogBody(mode.current.user, mode.current.server) - ConfirmDialog( - title = stringResource(R.string.remove_seerr_server), - body = body, - onCancel = { seerrDialogMode = SeerrDialogMode.None }, - onConfirm = { - seerrVm.removeServer() - seerrDialogMode = SeerrDialogMode.None - }, - elevation = 3.dp, - ) - } - - SeerrDialogMode.Add -> { - val currentUser by seerrVm.currentUser.collectAsState(null) - val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) - val serverAddedMessage = stringResource(R.string.seerr_server_added) - LaunchedEffect(status) { - if (status == LoadingState.Success) { - Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show() - seerrDialogMode = SeerrDialogMode.None - } - } - AddSeerServerDialog( - currentUsername = currentUser?.name, - status = status, - onSubmit = seerrVm::submitServer, - onResetStatus = seerrVm::resetStatus, - onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, - ) - } - - is SeerrDialogMode.Error -> { - val body = rememberSeerrDialogBody(mode.user, mode.server, mode.ex) - ConfirmDialog( - title = stringResource(R.string.remove_seerr_server), - body = body, - onCancel = { seerrDialogMode = SeerrDialogMode.None }, - onConfirm = { - seerrVm.removeServer() - seerrDialogMode = SeerrDialogMode.None - }, - bodyColor = MaterialTheme.colorScheme.error, - elevation = 3.dp, - ) - } - - SeerrDialogMode.None -> {} - } } + SeerrDialog( + seerrDialogMode = seerrDialogMode, + seerrConnection = seerrConnection, + onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, + seerrVm = seerrVm, + ) + if (showQuickConnectDialog) { val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending) val successMessage = stringResource(R.string.quick_connect_success) @@ -880,19 +832,3 @@ data class CacheUsage( val imageMemoryMax: Long, val imageDiskUsed: Long, ) - -private sealed interface SeerrDialogMode { - data object None : SeerrDialogMode - - data object Add : SeerrDialogMode - - data class Remove( - val current: CurrentSeerr, - ) : SeerrDialogMode - - data class Error( - val server: SeerrServer, - val user: SeerrUser, - val ex: Exception, - ) : SeerrDialogMode -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt index 5fa8c667..4a65b3d5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.util.LoadingState @Composable fun AddSeerrServerApiKey( + initialUrl: String, onSubmit: (url: String, apiKey: String) -> Unit, status: LoadingState, modifier: Modifier = Modifier, @@ -54,12 +55,18 @@ fun AddSeerrServerApiKey( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf("") } + var url by remember { mutableStateOf(initialUrl) } var apiKey by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + if (initialUrl.isBlank()) { + focusRequester.tryRequestFocus() + } else { + passwordFocusRequester.tryRequestFocus() + } + } Text( text = stringResource(R.string.enter_url_api_key), style = MaterialTheme.typography.titleMedium, @@ -152,6 +159,7 @@ fun AddSeerrServerApiKey( @Composable fun AddSeerrServerUsername( + initialUrl: String, onSubmit: (url: String, username: String, password: String) -> Unit, username: String, status: LoadingState, @@ -166,14 +174,22 @@ fun AddSeerrServerUsername( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf("") } + var url by remember { mutableStateOf(initialUrl) } var username by remember { mutableStateOf(username) } var password by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } val usernameFocusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + val focusRequesterToUse = + when { + url.isNotNullOrBlank() && username.isNotNullOrBlank() -> passwordFocusRequester + url.isNotNullOrBlank() -> usernameFocusRequester + else -> focusRequester + } + focusRequesterToUse.tryRequestFocus() + } Text( text = stringResource(R.string.username_or_password), style = MaterialTheme.typography.titleMedium, @@ -317,6 +333,7 @@ fun AddSeerrServerUsername( private fun AddSeerrServerUsernamePreview() { WholphinTheme { AddSeerrServerUsername( + initialUrl = "", onSubmit = { string: String, string1: String, string2: String -> }, username = "test", status = LoadingState.Pending, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt index d84b98db..cc68fe04 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -1,27 +1,161 @@ package com.github.damontecres.wholphin.ui.setup.seerr +import android.widget.Toast +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrUser +import com.github.damontecres.wholphin.services.CurrentSeerr +import com.github.damontecres.wholphin.services.SeerrState import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.ConfirmDialog 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.LoadingPage +import com.github.damontecres.wholphin.ui.components.ScrollableDialog +import com.github.damontecres.wholphin.ui.preferences.rememberSeerrDialogBody import com.github.damontecres.wholphin.util.LoadingState +@Composable +fun SeerrDialog( + seerrDialogMode: SeerrDialogMode, + seerrConnection: SeerrState, + onDismissRequest: () -> Unit, + seerrVm: SwitchSeerrViewModel = hiltViewModel(), +) { + val context = LocalContext.current + when (val mode = seerrDialogMode) { + is SeerrDialogMode.Remove -> { + val body = rememberSeerrDialogBody(mode.current.user, mode.current.server, seerrConnection.pluginProvided) + if (seerrConnection.pluginProvided) { + ScrollableDialog( + onDismissRequest = onDismissRequest, + ) { + item { + Text(body) + } + } + } else { + ConfirmDialog( + title = stringResource(R.string.remove_seerr_server), + body = body, + onCancel = onDismissRequest, + onConfirm = { + seerrVm.removeServer() + onDismissRequest.invoke() + }, + elevation = 3.dp, + ) + } + } + + SeerrDialogMode.Add -> { + val currentUser by seerrVm.currentUser.collectAsState(null) + val state by seerrVm.state.collectAsState() + val serverAddedMessage = stringResource(R.string.seerr_server_added) + LaunchedEffect(state.serverConnectionStatus) { + if (state.serverConnectionStatus == LoadingState.Success) { + Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show() + onDismissRequest.invoke() + } + } + when (state.loading) { + is LoadingState.Error, + LoadingState.Loading, + LoadingState.Pending, + -> { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + LoadingPage(Modifier.padding(16.dp)) + } + } + + LoadingState.Success -> { + AddSeerServerDialog( + initialUrl = state.initialUrl, + currentUsername = currentUser?.name, + status = state.serverConnectionStatus, + onSubmit = seerrVm::submitServer, + onResetStatus = seerrVm::resetStatus, + onDismissRequest = onDismissRequest, + ) + } + } + } + + is SeerrDialogMode.Error -> { + val body = rememberSeerrDialogBody(mode.user, mode.server, seerrConnection.pluginProvided, mode.ex) + if (seerrConnection.pluginProvided) { + ConfirmDialog( + title = stringResource(R.string.error), + body = body, + onCancel = onDismissRequest, + onConfirm = { + seerrVm.retryServerFromPlugin() + onDismissRequest.invoke() + }, + bodyColor = MaterialTheme.colorScheme.error, + elevation = 3.dp, + confirmStringRes = R.string.retry, + ) + } else { + ConfirmDialog( + title = stringResource(R.string.remove_seerr_server), + body = body, + onCancel = onDismissRequest, + onConfirm = { + seerrVm.removeServer() + onDismissRequest.invoke() + }, + bodyColor = MaterialTheme.colorScheme.error, + elevation = 3.dp, + ) + } + } + + SeerrDialogMode.None -> {} + } +} + +sealed interface SeerrDialogMode { + data object None : SeerrDialogMode + + data object Add : SeerrDialogMode + + data class Remove( + val current: CurrentSeerr, + ) : SeerrDialogMode + + data class Error( + val server: SeerrServer, + val user: SeerrUser, + val ex: Exception, + ) : SeerrDialogMode +} + @Composable fun AddSeerServerDialog( currentUsername: String?, + initialUrl: String?, status: LoadingState, onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, onResetStatus: () -> Unit, @@ -42,6 +176,7 @@ fun AddSeerServerDialog( properties = DialogProperties(usePlatformDefaultWidth = false), ) { AddSeerrServerUsername( + initialUrl = initialUrl ?: "", onSubmit = { url, username, password -> onSubmit.invoke(url, username, password, auth) }, @@ -57,6 +192,7 @@ fun AddSeerServerDialog( onDismissRequest = { authMethod = null }, ) { AddSeerrServerApiKey( + initialUrl = initialUrl ?: "", onSubmit = { url, apiKey -> onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt index 773f6c74..2fde3eae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -3,11 +3,13 @@ package com.github.damontecres.wholphin.ui.setup.seerr import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.services.ServerPluginApi import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.LoadingState @@ -15,6 +17,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import jakarta.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl @@ -28,11 +31,29 @@ class SwitchSeerrViewModel private val seerrServerRepository: SeerrServerRepository, private val seerrService: SeerrService, private val serverRepository: ServerRepository, + private val serverPluginApi: ServerPluginApi, ) : ViewModel() { val currentUser = serverRepository.currentUserFlow val currentSeerrServer = seerrServerRepository.currentServer - val serverConnectionStatus = MutableStateFlow(LoadingState.Pending) + private val _state = MutableStateFlow(SwitchSeerrState()) + val state: StateFlow = _state + + init { + if (serverRepository.serverPluginInstalled.value) { + viewModelScope.launchIO { + try { + serverPluginApi.fetchSeerrSettings()?.serverUrl?.let { serverUrl -> + _state.update { it.copy(initialUrl = serverUrl) } + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching seerr settings") + } finally { + _state.update { it.copy(loading = LoadingState.Success) } + } + } + } + } fun submitServer( url: String, @@ -41,13 +62,21 @@ class SwitchSeerrViewModel authMethod: SeerrAuthMethod, ) { viewModelScope.launchIO { - serverConnectionStatus.update { LoadingState.Loading } + _state.update { it.copy(serverConnectionStatus = LoadingState.Loading) } val urls = try { createUrls(url) } catch (ex: IllegalArgumentException) { showToast(context, "Invalid URL") - serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) } + _state.update { + it.copy( + serverConnectionStatus = + LoadingState.Error( + "Invalid URL", + ex, + ), + ) + } return@launchIO } Timber.v("Urls to try: %s", urls) @@ -98,7 +127,7 @@ class SwitchSeerrViewModel ) } } - serverConnectionStatus.update { LoadingState.Success } + _state.update { it.copy(serverConnectionStatus = LoadingState.Success) } } else { val message = results @@ -107,7 +136,7 @@ class SwitchSeerrViewModel "$url - ${s?.localizedMessage}" }.joinToString("\n") showToast(context, "Could not connect") - serverConnectionStatus.update { LoadingState.Error(message) } + _state.update { it.copy(serverConnectionStatus = LoadingState.Error(message)) } } } } @@ -121,11 +150,31 @@ class SwitchSeerrViewModel } } + fun retryServerFromPlugin() { + viewModelScope.launchIO { + val settings = serverPluginApi.fetchSeerrSettings() + if (settings != null) { + val result = seerrServerRepository.usePluginProvidedServer(settings) + if (!result) { + showToast(context, context.getString(R.string.voice_error_server)) + } + } else { + seerrServerRepository.clear() + } + } + } + fun resetStatus() { - serverConnectionStatus.update { LoadingState.Pending } + _state.update { it.copy(serverConnectionStatus = LoadingState.Pending) } } } +data class SwitchSeerrState( + val loading: LoadingState = LoadingState.Pending, + val serverConnectionStatus: LoadingState = LoadingState.Pending, + val initialUrl: String? = null, +) + fun createUrls(url: String): List { val urls = mutableListOf() if (url.startsWith("http://") || url.startsWith("https://")) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3ffe79fc..e263261c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -977,4 +977,6 @@ Server user profile Server default User web client + Managed by server + Error