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,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) {
private val _current = MutableStateFlow<CurrentSeerr?>(null)
val current: StateFlow<CurrentSeerr?> = _current
val currentServer: Flow<SeerrServer?> = current.map { it?.server }
val currentUser: Flow<SeerrUser?> = current.map { it?.user }
private val _connection =
MutableStateFlow<SeerrConnectionStatus>(SeerrConnectionStatus.NotConfigured)
val connection: StateFlow<SeerrConnectionStatus> = _connection
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
*/
val active: Flow<Boolean> = current.map { it != null && seerrApi.active }
val active: Flow<Boolean> =
connection.map { it is SeerrConnectionStatus.Success && seerrApi.active }
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)
}
@ -65,8 +80,10 @@ class SeerrServerRepository
userConfig: SeerrUserConfig,
) {
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
_current.update {
CurrentSeerr(server, user, userConfig, publicSettings)
_connection.update {
SeerrConnectionStatus.Success(
CurrentSeerr(server, user, userConfig, publicSettings),
)
}
}
@ -154,7 +171,7 @@ class SeerrServerRepository
}
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)
clear()
}
@ -165,6 +182,19 @@ class SeerrServerRepository
*/
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(
val server: SeerrServer,
val user: SeerrUser,
@ -244,45 +274,34 @@ class UserSwitchListener
launchIO {
seerrServerDao
.getUsersByJellyfinUser(user.rowId)
.firstOrNull()
.lastOrNull()
?.let { seerrUser ->
val server =
seerrServerDao.getServer(seerrUser.serverId)?.server
if (server != null) {
Timber.i("Found a seerr user & server")
seerrApi.update(server.url, seerrUser.credential)
val userConfig =
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
try {
try {
seerrApi.update(server.url, seerrUser.credential)
val userConfig =
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
login(
seerrApi.api,
seerrUser.authMethod,
seerrUser.username,
seerrUser.password,
)
} catch (ex: Exception) {
Timber.w(
ex,
"Error logging into %s",
server.url,
)
seerrServerRepository.clear()
return@let
}
} else {
try {
} else {
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,
properties: DialogProperties = DialogProperties(),
elevation: Dp = 8.dp,
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
) = BasicDialog(
onDismissRequest = onCancel,
properties = properties,
elevation = elevation,
content = {
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier)
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor)
},
)
@ -427,6 +428,7 @@ fun ConfirmDialogContent(
onCancel: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
@ -446,7 +448,7 @@ fun ConfirmDialogContent(
item {
Text(
text = body,
color = MaterialTheme.colorScheme.onSurface,
color = bodyColor,
)
}
}

View file

@ -73,7 +73,8 @@ class DiscoverMovieViewModel
val canCancelRequest = MutableStateFlow(false)
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()

View file

@ -64,7 +64,7 @@ class SeerrRequestsViewModel
viewModelScope.launchIO {
backdropService.clearBackdrop()
}
seerrServerRepository.current
seerrServerRepository.connection
.onEach { user ->
state.update { it.copy(requests = DataLoadingState.Loading) }
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.basicPreferences
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.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.ifElse
@ -90,7 +91,7 @@ fun PreferencesContent(
var showPinFlow by remember { mutableStateOf(false) }
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 showQuickConnectDialog by remember { mutableStateOf(false) }
@ -385,19 +386,29 @@ fun PreferencesContent(
ClickPreference(
title = stringResource(pref.title),
onClick = {
if (seerrIntegrationEnabled) {
seerrDialogMode = SeerrDialogMode.Remove
} else {
seerrVm.resetStatus()
seerrDialogMode = SeerrDialogMode.Add
}
seerrDialogMode =
when (val conn = seerrConnection) {
is SeerrConnectionStatus.Error -> {
SeerrDialogMode.Error(conn.serverUrl, conn.ex)
}
SeerrConnectionStatus.NotConfigured -> {
SeerrDialogMode.Add
}
is SeerrConnectionStatus.Success -> {
SeerrDialogMode.Remove(
conn.current.server.url,
)
}
}
},
modifier = Modifier,
summary =
if (seerrIntegrationEnabled) {
stringResource(R.string.enabled)
} else {
null
when (seerrConnection) {
is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server)
SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server)
is SeerrConnectionStatus.Success -> stringResource(R.string.enabled)
},
onLongClick = {},
interactionSource = interactionSource,
@ -479,11 +490,11 @@ fun PreferencesContent(
)
}
}
when (seerrDialogMode) {
SeerrDialogMode.Remove -> {
when (val mode = seerrDialogMode) {
is SeerrDialogMode.Remove -> {
ConfirmDialog(
title = stringResource(R.string.remove_seerr_server),
body = currentServer?.url ?: "",
body = mode.serverUrl,
onCancel = { seerrDialogMode = SeerrDialogMode.None },
onConfirm = {
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 -> {}
}
}
@ -580,10 +613,17 @@ data class CacheUsage(
val imageDiskUsed: Long,
)
private sealed class SeerrDialogMode {
data object None : SeerrDialogMode()
private sealed interface 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 androidx.datastore.core.DataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
@ -46,10 +44,7 @@ class PreferencesViewModel
RememberTabManager by rememberTabManager {
val currentUser get() = serverRepository.currentUser
val seerrEnabled =
seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser ->
seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId
}
val seerrConnection = seerrServerRepository.connection
private val _quickConnectStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
val quickConnectStatus: StateFlow<LoadingState> = _quickConnectStatus