Fixes for Jellyseerr login issues (#1031)

## Description
Fixes some issues with Jellyseerr login. Also makes error message more
clear by showing the URLs that were tested and the error that occurred.

Also fixes an issue where you could not remove a Jellyseerr server if it
was previously configured but can no longer connect.

### Related issues
Fixes #1028
Should fix #1022
Related to #747

### Testing
Emulator, tested adding and removing valid & invalid IPs

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-03-04 15:11:09 -05:00 committed by GitHub
parent 6867ba7657
commit ce6461d23b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 165 additions and 96 deletions

View file

@ -47,7 +47,7 @@ interface SeerrServerDao {
suspend fun deleteUser( suspend fun deleteUser(
serverId: Int, serverId: Int,
jellyfinUserRowId: Int, jellyfinUserRowId: Int,
) ): Int
suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId) suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId)

View file

@ -11,6 +11,7 @@ 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.JellyfinUser
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.SeerrPermission
import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrServer
@ -24,8 +25,10 @@ import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.supervisorScope
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -162,7 +165,7 @@ class SeerrServerRepository
apiKey, apiKey,
okHttpClient okHttpClient
.newBuilder() .newBuilder()
.connectTimeout(5.seconds) .connectTimeout(2.seconds)
.readTimeout(6.seconds) .readTimeout(6.seconds)
.build(), .build(),
) )
@ -170,10 +173,11 @@ class SeerrServerRepository
return LoadingState.Success return LoadingState.Success
} }
suspend fun removeServer() { suspend fun removeServerForCurrentUser(): Boolean {
val current = (_connection.value as? SeerrConnectionStatus.Success)?.current ?: return val current = current.firstOrNull() ?: return false
seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) val rows = seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
clear() clear()
return rows > 0
} }
} }
@ -266,6 +270,14 @@ class UserSwitchListener
seerrServerRepository.clear() seerrServerRepository.clear()
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY } homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
if (user != null) { if (user != null) {
switchUser(user)
}
}
}
}
private suspend fun switchUser(user: JellyfinUser) =
supervisorScope {
// Check for home settings // Check for home settings
launchIO { launchIO {
homeSettingsService.loadCurrentSettings(user.id) homeSettingsService.loadCurrentSettings(user.id)
@ -293,7 +305,11 @@ class UserSwitchListener
} else { } else {
seerrApi.api.usersApi.authMeGet() seerrApi.api.usersApi.authMeGet()
} }
seerrServerRepository.set(server, seerrUser, userConfig) seerrServerRepository.set(
server,
seerrUser,
userConfig,
)
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.w( Timber.w(
ex, ex,
@ -307,6 +323,3 @@ class UserSwitchListener
} }
} }
} }
}
}
}

View file

@ -554,6 +554,7 @@ fun PreferencesContent(
currentUsername = currentUser?.name, currentUsername = currentUser?.name,
status = status, status = status,
onSubmit = seerrVm::submitServer, onSubmit = seerrVm::submitServer,
onResetStatus = seerrVm::resetStatus,
onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
) )
} }

View file

@ -4,7 +4,6 @@ import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@ -66,15 +65,19 @@ fun AddSeerrServerApiKey(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.align(Alignment.CenterHorizontally),
) )
val labelWidth = 90.dp
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
) { ) {
Text( Text(
text = stringResource(R.string.url), text = stringResource(R.string.url),
modifier = Modifier.padding(end = 8.dp), modifier =
Modifier
.width(labelWidth)
.padding(end = 8.dp),
) )
EditTextBox( EditTextBox(
value = url, value = url,
@ -105,7 +108,10 @@ fun AddSeerrServerApiKey(
) { ) {
Text( Text(
text = stringResource(R.string.api_key), text = stringResource(R.string.api_key),
modifier = Modifier.padding(end = 8.dp), modifier =
Modifier
.width(labelWidth)
.padding(end = 8.dp),
) )
EditTextBox( EditTextBox(
value = apiKey, value = apiKey,
@ -173,7 +179,7 @@ fun AddSeerrServerUsername(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.align(Alignment.CenterHorizontally),
) )
val labelWidth = 90.dp val labelWidth = 90.dp
Row( Row(

View file

@ -1,12 +1,16 @@
package com.github.damontecres.wholphin.ui.setup.seerr package com.github.damontecres.wholphin.ui.setup.seerr
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.BasicDialog
@ -20,6 +24,7 @@ fun AddSeerServerDialog(
currentUsername: String?, currentUsername: String?,
status: LoadingState, status: LoadingState,
onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit,
onResetStatus: () -> Unit,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) } var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) }
@ -34,6 +39,7 @@ fun AddSeerServerDialog(
-> { -> {
BasicDialog( BasicDialog(
onDismissRequest = { authMethod = null }, onDismissRequest = { authMethod = null },
properties = DialogProperties(usePlatformDefaultWidth = false),
) { ) {
AddSeerrServerUsername( AddSeerrServerUsername(
onSubmit = { url, username, password -> onSubmit = { url, username, password ->
@ -41,6 +47,7 @@ fun AddSeerServerDialog(
}, },
username = currentUsername ?: "", username = currentUsername ?: "",
status = status, status = status,
modifier = Modifier.widthIn(min = 320.dp),
) )
} }
} }
@ -54,6 +61,7 @@ fun AddSeerServerDialog(
onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY) onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY)
}, },
status = status, status = status,
modifier = Modifier.widthIn(min = 320.dp),
) )
} }
} }
@ -61,7 +69,10 @@ fun AddSeerServerDialog(
null -> { null -> {
ChooseSeerrLoginType( ChooseSeerrLoginType(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onChoose = { authMethod = it }, onChoose = {
onResetStatus.invoke()
authMethod = it
},
) )
} }
} }

View file

@ -50,10 +50,10 @@ class SwitchSeerrViewModel
serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) } serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) }
return@launchIO return@launchIO
} }
var result: LoadingState = LoadingState.Error("No url") Timber.v("Urls to try: %s", urls)
val results = mutableMapOf<HttpUrl, LoadingState>()
for (url in urls) { for (url in urls) {
Timber.d("Trying %s", url) Timber.d("Trying %s", url)
result =
try { try {
seerrServerRepository.testConnection( seerrServerRepository.testConnection(
authMethod = authMethod, authMethod = authMethod,
@ -61,20 +61,24 @@ class SwitchSeerrViewModel
username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY }, username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY },
passwordOrApiKey = passwordOrApiKey, passwordOrApiKey = passwordOrApiKey,
) )
results[url] = LoadingState.Success
break
} catch (ex: ClientException) { } catch (ex: ClientException) {
Timber.w(ex, "ClientException logging in") Timber.w(ex, "ClientException logging in %s", url)
if (ex.statusCode == 401 || ex.statusCode == 403) { if (ex.statusCode == 401 || ex.statusCode == 403) {
showToast(context, "Invalid credentials") showToast(context, "Invalid credentials")
result = LoadingState.Error("Invalid credentials", ex) results[url] = LoadingState.Error("Invalid credentials", ex)
break
} else { } else {
LoadingState.Error("Could not connect with URL") results[url] = LoadingState.Error("Could not connect with URL")
} }
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.w(ex, "Exception logging in") Timber.w(ex, "ClientException logging in %s", url)
LoadingState.Error(ex) results[url] = LoadingState.Error(ex)
} }
if (result is LoadingState.Success) { }
val result = results.filter { (url, state) -> state is LoadingState.Success }
if (result.isNotEmpty()) {
val url = result.keys.first()
when (authMethod) { when (authMethod) {
SeerrAuthMethod.LOCAL, SeerrAuthMethod.LOCAL,
SeerrAuthMethod.JELLYFIN, SeerrAuthMethod.JELLYFIN,
@ -94,19 +98,25 @@ class SwitchSeerrViewModel
) )
} }
} }
break } else {
val message =
results
.map { (url, state) ->
val s = state as? LoadingState.Error
"$url - ${s?.localizedMessage}"
}.joinToString("\n")
showToast(context, "Could not connect")
serverConnectionStatus.update { LoadingState.Error(message) }
} }
} }
if (result is LoadingState.Error) {
showToast(context, "Error: ${result.message}")
}
serverConnectionStatus.update { result }
}
} }
fun removeServer() { fun removeServer() {
viewModelScope.launchIO { viewModelScope.launchIO {
seerrServerRepository.removeServer() val result = seerrServerRepository.removeServerForCurrentUser()
if (!result) {
showToast(context, "Could not remove server")
}
} }
} }

View file

@ -62,4 +62,32 @@ class TestSeerr {
) )
Assert.assertEquals(expected, urls) Assert.assertEquals(expected, urls)
} }
@Test
fun testCreateUrls5() {
val urls =
createUrls("10.0.0.2:443")
.map { it.toString() }
val expected =
listOf(
"http://10.0.0.2:443/api/v1",
"https://10.0.0.2/api/v1",
)
Assert.assertEquals(expected, urls)
}
@Test
fun testCreateUrls6() {
val urls =
createUrls("10.0.0.2:8080")
.map { it.toString() }
val expected =
listOf(
"http://10.0.0.2:8080/api/v1",
"https://10.0.0.2:8080/api/v1",
)
Assert.assertEquals(expected, urls)
}
} }