Better Jellyseerr connection error handling (#933)

## Description
Show Jellyseerr connection errors in settings. If an error occurred
trying to login, in setting it will show "Server error" and clicking
gives more error info and option to remove the server.

### Related issues
Fixes #907 

### Testing
Emulator with simulated errors

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-02-20 14:29:58 -05:00 committed by GitHub
parent dfd9acf561
commit 4f8f69c438
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 120 additions and 63 deletions

View file

@ -44,18 +44,33 @@ class SeerrServerRepository
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) { ) {
private val _current = MutableStateFlow<CurrentSeerr?>(null) private val _connection =
val current: StateFlow<CurrentSeerr?> = _current MutableStateFlow<SeerrConnectionStatus>(SeerrConnectionStatus.NotConfigured)
val currentServer: Flow<SeerrServer?> = current.map { it?.server } val connection: StateFlow<SeerrConnectionStatus> = _connection
val currentUser: Flow<SeerrUser?> = current.map { it?.user }
val current: Flow<CurrentSeerr?> =
_connection.map { (it as? SeerrConnectionStatus.Success)?.current }
val currentServer: Flow<SeerrServer?> =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
val currentUser: Flow<SeerrUser?> =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
/** /**
* Whether Seerr integration is currently active of not * Whether Seerr integration is currently active of not
*/ */
val active: Flow<Boolean> = current.map { it != null && seerrApi.active } val active: Flow<Boolean> =
connection.map { it is SeerrConnectionStatus.Success && seerrApi.active }
fun clear() { fun clear() {
_current.update { null } _connection.update { SeerrConnectionStatus.NotConfigured }
seerrApi.update("", null)
}
fun error(
serverUrl: String,
exception: Exception,
) {
_connection.update { SeerrConnectionStatus.Error(serverUrl, exception) }
seerrApi.update("", null) seerrApi.update("", null)
} }
@ -65,8 +80,10 @@ class SeerrServerRepository
userConfig: SeerrUserConfig, userConfig: SeerrUserConfig,
) { ) {
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet() val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
_current.update { _connection.update {
CurrentSeerr(server, user, userConfig, publicSettings) SeerrConnectionStatus.Success(
CurrentSeerr(server, user, userConfig, publicSettings),
)
} }
} }
@ -154,7 +171,7 @@ class SeerrServerRepository
} }
suspend fun removeServer() { suspend fun removeServer() {
val current = _current.value ?: return val current = (_connection.value as? SeerrConnectionStatus.Success)?.current ?: return
seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
clear() clear()
} }
@ -165,6 +182,19 @@ class SeerrServerRepository
*/ */
typealias SeerrUserConfig = User typealias SeerrUserConfig = User
sealed interface SeerrConnectionStatus {
data object NotConfigured : SeerrConnectionStatus
data class Error(
val serverUrl: String,
val ex: Exception,
) : SeerrConnectionStatus
data class Success(
val current: CurrentSeerr,
) : SeerrConnectionStatus
}
data class CurrentSeerr( data class CurrentSeerr(
val server: SeerrServer, val server: SeerrServer,
val user: SeerrUser, val user: SeerrUser,
@ -244,45 +274,34 @@ class UserSwitchListener
launchIO { launchIO {
seerrServerDao seerrServerDao
.getUsersByJellyfinUser(user.rowId) .getUsersByJellyfinUser(user.rowId)
.firstOrNull() .lastOrNull()
?.let { seerrUser -> ?.let { seerrUser ->
val server = val server =
seerrServerDao.getServer(seerrUser.serverId)?.server seerrServerDao.getServer(seerrUser.serverId)?.server
if (server != null) { if (server != null) {
Timber.i("Found a seerr user & server") Timber.i("Found a seerr user & server")
seerrApi.update(server.url, seerrUser.credential) try {
val userConfig = seerrApi.update(server.url, seerrUser.credential)
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { val userConfig =
try { if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
login( login(
seerrApi.api, seerrApi.api,
seerrUser.authMethod, seerrUser.authMethod,
seerrUser.username, seerrUser.username,
seerrUser.password, seerrUser.password,
) )
} catch (ex: Exception) { } else {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.clear()
return@let
}
} else {
try {
seerrApi.api.usersApi.authMeGet() seerrApi.api.usersApi.authMeGet()
} catch (ex: Exception) {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.clear()
return@let
} }
} seerrServerRepository.set(server, seerrUser, userConfig)
seerrServerRepository.set(server, seerrUser, userConfig) } catch (ex: Exception) {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.error(server.url, ex)
}
} }
} }
} }

View file

@ -408,12 +408,13 @@ fun ConfirmDialog(
onConfirm: () -> Unit, onConfirm: () -> Unit,
properties: DialogProperties = DialogProperties(), properties: DialogProperties = DialogProperties(),
elevation: Dp = 8.dp, elevation: Dp = 8.dp,
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
) = BasicDialog( ) = BasicDialog(
onDismissRequest = onCancel, onDismissRequest = onCancel,
properties = properties, properties = properties,
elevation = elevation, elevation = elevation,
content = { content = {
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier) ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor)
}, },
) )
@ -427,6 +428,7 @@ fun ConfirmDialogContent(
onCancel: () -> Unit, onCancel: () -> Unit,
onConfirm: () -> Unit, onConfirm: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
) { ) {
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@ -446,7 +448,7 @@ fun ConfirmDialogContent(
item { item {
Text( Text(
text = body, text = body,
color = MaterialTheme.colorScheme.onSurface, color = bodyColor,
) )
} }
} }

View file

@ -73,7 +73,8 @@ class DiscoverMovieViewModel
val canCancelRequest = MutableStateFlow(false) val canCancelRequest = MutableStateFlow(false)
val userConfig = seerrServerRepository.current.map { it?.config } val userConfig = seerrServerRepository.current.map { it?.config }
val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } val request4kEnabled =
seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false }
init { init {
init() init()

View file

@ -64,7 +64,7 @@ class SeerrRequestsViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
backdropService.clearBackdrop() backdropService.clearBackdrop()
} }
seerrServerRepository.current seerrServerRepository.connection
.onEach { user -> .onEach { user ->
state.update { it.copy(requests = DataLoadingState.Loading) } state.update { it.copy(requests = DataLoadingState.Loading) }
if (user != null) { if (user != null) {

View file

@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences import com.github.damontecres.wholphin.preferences.basicPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.services.SeerrConnectionStatus
import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
@ -90,7 +91,7 @@ fun PreferencesContent(
var showPinFlow by remember { mutableStateOf(false) } var showPinFlow by remember { mutableStateOf(false) }
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) val seerrConnection by viewModel.seerrConnection.collectAsState()
var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) } var seerrDialogMode by remember { mutableStateOf<SeerrDialogMode>(SeerrDialogMode.None) }
var showQuickConnectDialog by remember { mutableStateOf(false) } var showQuickConnectDialog by remember { mutableStateOf(false) }
@ -385,19 +386,29 @@ fun PreferencesContent(
ClickPreference( ClickPreference(
title = stringResource(pref.title), title = stringResource(pref.title),
onClick = { onClick = {
if (seerrIntegrationEnabled) { seerrDialogMode =
seerrDialogMode = SeerrDialogMode.Remove when (val conn = seerrConnection) {
} else { is SeerrConnectionStatus.Error -> {
seerrVm.resetStatus() SeerrDialogMode.Error(conn.serverUrl, conn.ex)
seerrDialogMode = SeerrDialogMode.Add }
}
SeerrConnectionStatus.NotConfigured -> {
SeerrDialogMode.Add
}
is SeerrConnectionStatus.Success -> {
SeerrDialogMode.Remove(
conn.current.server.url,
)
}
}
}, },
modifier = Modifier, modifier = Modifier,
summary = summary =
if (seerrIntegrationEnabled) { when (seerrConnection) {
stringResource(R.string.enabled) is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server)
} else { SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server)
null is SeerrConnectionStatus.Success -> stringResource(R.string.enabled)
}, },
onLongClick = {}, onLongClick = {},
interactionSource = interactionSource, interactionSource = interactionSource,
@ -479,11 +490,11 @@ fun PreferencesContent(
) )
} }
} }
when (seerrDialogMode) { when (val mode = seerrDialogMode) {
SeerrDialogMode.Remove -> { is SeerrDialogMode.Remove -> {
ConfirmDialog( ConfirmDialog(
title = stringResource(R.string.remove_seerr_server), title = stringResource(R.string.remove_seerr_server),
body = currentServer?.url ?: "", body = mode.serverUrl,
onCancel = { seerrDialogMode = SeerrDialogMode.None }, onCancel = { seerrDialogMode = SeerrDialogMode.None },
onConfirm = { onConfirm = {
seerrVm.removeServer() seerrVm.removeServer()
@ -510,6 +521,28 @@ fun PreferencesContent(
) )
} }
is SeerrDialogMode.Error -> {
val errorStr = stringResource(R.string.voice_error_server)
val body =
remember(mode) {
"""
${mode.serverUrl}
$errorStr: ${mode.ex.localizedMessage}
""".trimIndent()
}
ConfirmDialog(
title = stringResource(R.string.remove_seerr_server),
body = body,
onCancel = { seerrDialogMode = SeerrDialogMode.None },
onConfirm = {
seerrVm.removeServer()
seerrDialogMode = SeerrDialogMode.None
},
bodyColor = MaterialTheme.colorScheme.error,
)
}
SeerrDialogMode.None -> {} SeerrDialogMode.None -> {}
} }
} }
@ -580,10 +613,17 @@ data class CacheUsage(
val imageDiskUsed: Long, val imageDiskUsed: Long,
) )
private sealed class SeerrDialogMode { private sealed interface SeerrDialogMode {
data object None : SeerrDialogMode() data object None : SeerrDialogMode
data object Add : SeerrDialogMode() data object Add : SeerrDialogMode
data object Remove : SeerrDialogMode() data class Remove(
val serverUrl: String,
) : SeerrDialogMode
data class Error(
val serverUrl: String,
val ex: Exception,
) : SeerrDialogMode
} }

View file

@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.preferences
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
@ -22,7 +21,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.DeviceInfo
@ -46,10 +44,7 @@ class PreferencesViewModel
RememberTabManager by rememberTabManager { RememberTabManager by rememberTabManager {
val currentUser get() = serverRepository.currentUser val currentUser get() = serverRepository.currentUser
val seerrEnabled = val seerrConnection = seerrServerRepository.connection
seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser ->
seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId
}
private val _quickConnectStatus = MutableStateFlow<LoadingState>(LoadingState.Pending) private val _quickConnectStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
val quickConnectStatus: StateFlow<LoadingState> = _quickConnectStatus val quickConnectStatus: StateFlow<LoadingState> = _quickConnectStatus