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
This commit is contained in:
Damontecres 2026-10-26 14:18:56 -04:00 • committed by GitHub
parent 90a2d6d459
commit f66746cf47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 454 additions and 160 deletions

View file

@ -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,
)

View file

@ -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>(SeerrConnectionStatus.NotConfigured)
val connection: StateFlow<SeerrConnectionStatus> = _connection
private val _connection = MutableStateFlow<SeerrState>(SeerrState())
val connection: StateFlow<SeerrState> = _connection
val current: Flow<CurrentSeerr?> =
_connection.map { (it as? SeerrConnectionStatus.Success)?.current }
_connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current }
val currentServer: Flow<SeerrServer?> =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.server }
val currentUser: Flow<SeerrUser?> =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.user }
val currentUserId: Flow<Int?> = current.map { it?.config?.id }
/**
* Whether Seerr integration is currently active of not
*/
val active: Flow<Boolean> =
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

View file

@ -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 <reified T> executeRequest(path: String): T? {
val url = createUrl(path) ?: return null
val request = Request(url.toHttpUrl())
return executeRequest<T>(request)
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun fetchHomePageSettings(): HomePageSettings? =
private suspend inline fun <reified T> 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<HomePageSettings>(res.body.byteStream())
.takeIf { it.rows.isNotEmpty() }
json.decodeFromStream<T>(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<HomePageSettings>(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)
}

View file

@ -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)
}
}
}
}
}
}
}

View file

@ -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,
)
}

View file

@ -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 {
""
}

View file

@ -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
}

View file

@ -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,

View file

@ -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)
},

View file

@ -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>(LoadingState.Pending)
private val _state = MutableStateFlow<SwitchSeerrState>(SwitchSeerrState())
val state: StateFlow<SwitchSeerrState> = _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<HttpUrl> {
val urls = mutableListOf<HttpUrl>()
if (url.startsWith("http://") || url.startsWith("https://")) {

View file

@ -977,4 +977,6 @@
<string name="home_settings_source_server_profile">Server user profile</string>
<string name="home_settings_source_plugin">Server default</string>
<string name="home_settings_source_web">User web client</string>
<string name="managed_by_server">Managed by server</string>
<string name="error">Error</string>
</resources>