From 6eaa5c281c77c1b9921c352957e30099375138a2 Mon Sep 17 00:00:00 2001 From: Mario Reisinger Date: Tue, 23 Jun 2026 20:24:44 +0200 Subject: [PATCH 1/3] Auto-configure Seerr from Wholphin server plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consume the new /wholphin/seerrsettings endpoint to set up Seerr on first Jellyfin login: server URL, auth method (ApiKey/Local/Jellyfin) and credentials are pushed by the plugin so the user doesn't have to configure Seerr manually. Supports useCurrentUser=true for the Jellyfin auth method, which stashes the in-memory Jellyfin password during password-based login and reuses it for Seerr. On QuickConnect / token-based logins the password is not available — the URL and username are pre-filled in the Add Seerr Server dialog so the user only enters the password once. If Seerr is unreachable during the initial setup, credentials are persisted before the login attempt so subsequent app starts retry automatically until Seerr is back. QuickConnect logins clear any previously stored Jellyfin password to avoid using stale credentials. --- .../data/model/SeerrServerPluginSettings.kt | 47 +++++ .../services/SeerrServerRepository.kt | 175 +++++++++++++----- .../wholphin/services/ServerPluginApi.kt | 25 ++- .../wholphin/services/UserSwitchListener.kt | 145 +++++++++++---- .../ui/preferences/PreferencesContent.kt | 2 + .../wholphin/ui/setup/SwitchUserViewModel.kt | 9 + .../wholphin/ui/setup/seerr/AddSeerrServer.kt | 6 +- .../ui/setup/seerr/AddSeerrServerDialog.kt | 3 + .../ui/setup/seerr/SwitchSeerrViewModel.kt | 13 ++ 9 files changed, 338 insertions(+), 87 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServerPluginSettings.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServerPluginSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServerPluginSettings.kt new file mode 100644 index 000000000..eb98246e7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServerPluginSettings.kt @@ -0,0 +1,47 @@ +package com.github.damontecres.wholphin.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class SeerrPluginLoginType { + @SerialName("None") + NONE, + + @SerialName("ApiKey") + API_KEY, + + @SerialName("Jellyfin") + JELLYFIN, + + @SerialName("Local") + LOCAL, +} + +@Serializable +data class SeerrPluginJellyfinLogin( + val useCurrentUser: Boolean = false, + val username: String? = null, + val password: String? = null, +) + +@Serializable +data class SeerrPluginLocalLogin( + val username: String? = null, + val password: String? = null, +) + +@Serializable +data class SeerrPluginLogin( + val type: SeerrPluginLoginType = SeerrPluginLoginType.NONE, + val apiKey: String? = null, + val jellyfin: SeerrPluginJellyfinLogin? = null, + val local: SeerrPluginLocalLogin? = null, +) + +@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 90d9526be..d565efb75 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 @@ -7,12 +7,14 @@ import com.github.damontecres.wholphin.api.seerr.model.PublicSettings import com.github.damontecres.wholphin.api.seerr.model.User import com.github.damontecres.wholphin.data.SeerrServerDao 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.SeerrPermission 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.flow.Flow @@ -23,6 +25,9 @@ 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 java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.seconds @@ -43,6 +48,39 @@ class SeerrServerRepository MutableStateFlow(SeerrConnectionStatus.NotConfigured) val connection: StateFlow = _connection + // Buffer for the Jellyfin password from the most recent password-based login — + // consumed once by the auto-setup flow if the plugin asks for useCurrentUser. + // Memory-only; we never want to persist a Jellyfin cleartext password. + private val pendingJellyfinPassword = AtomicReference(null) + + // Signal set by the QuickConnect login flow before changeUser, so the upcoming + // switchUser knows to wipe stale Jellyfin passwords from any SeerrUser rows. + private val clearJellyfinPasswordRequested = AtomicBoolean(false) + + fun stashJellyfinPasswordForAutoSetup(password: String?) { + pendingJellyfinPassword.set(password) + } + + fun consumeJellyfinPassword(): String? = pendingJellyfinPassword.getAndSet(null) + + fun requestClearJellyfinPasswordOnNextSwitch() { + clearJellyfinPasswordRequested.set(true) + } + + suspend fun consumeAndClearPasswordsIfRequested(jellyfinUserRowId: Int) { + if (!clearJellyfinPasswordRequested.getAndSet(false)) return + seerrServerDao + .getUsersByJellyfinUser(jellyfinUserRowId) + .filter { it.authMethod == SeerrAuthMethod.JELLYFIN && it.password.isNotNullOrBlank() } + .forEach { user -> + Timber.i( + "Clearing stored Jellyfin password for SeerrUser %s after QuickConnect login", + user.username, + ) + seerrServerDao.addUser(user.copy(password = null)) + } + } + val current: Flow = _connection.map { (it as? SeerrConnectionStatus.Success)?.current } val currentServer: Flow = @@ -51,6 +89,25 @@ class SeerrServerRepository connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } val currentUserId: Flow = current.map { it?.config?.id } + private data class SetupTarget( + val server: SeerrServer, + val jellyfinUser: JellyfinUser, + ) { + fun seerrUser( + authMethod: SeerrAuthMethod, + username: String? = null, + password: String? = null, + credential: String? = null, + ) = SeerrUser( + jellyfinUserRowId = jellyfinUser.rowId, + serverId = server.id, + authMethod = authMethod, + username = username, + password = password, + credential = credential, + ) + } + /** * Whether Seerr integration is currently active of not */ @@ -88,62 +145,86 @@ class SeerrServerRepository url: String, apiKey: String, ) { - var server = seerrServerDao.getServer(url) - if (server == null) { - seerrServerDao.addServer(SeerrServer(url = url)) - server = seerrServerDao.getServer(url) - } - server?.server?.let { server -> - serverRepository.currentUser?.let { jellyfinUser -> - // TODO test api key - val user = - SeerrUser( - jellyfinUserRowId = jellyfinUser.rowId, - serverId = server.id, - authMethod = SeerrAuthMethod.API_KEY, - username = null, - password = null, - credential = apiKey, - ) - seerrServerDao.addUser(user) - - seerrApi.update(server.url, apiKey) - val userConfig = seerrApi.api.usersApi.authMeGet() - set(server, user, userConfig) - } + val target = ensureServerAndCurrentUser(url) ?: return + seerrApi.update(target.server.url, apiKey) + val userConfig = seerrApi.api.usersApi.authMeGet() + val user = target.seerrUser(SeerrAuthMethod.API_KEY, credential = apiKey) + seerrServerDao.addUser(user) + set(target.server, user, userConfig) + } + + private suspend fun ensureServerAndCurrentUser(url: String): SetupTarget? { + val server = + seerrServerDao.getServer(url)?.server + ?: run { + seerrServerDao.addServer(SeerrServer(url = url)) + seerrServerDao.getServer(url)?.server + } + ?: return null + val jellyfinUser = serverRepository.currentUser ?: return null + return SetupTarget(server, jellyfinUser) + } + + // Persists credentials *before* the login attempt — if Seerr is down, the existing-user + // branch in [UserSwitchListener] retries on the next app start. + suspend fun persistAndTryLogin( + url: String, + authMethod: SeerrAuthMethod, + username: String, + password: String, + ) { + val target = ensureServerAndCurrentUser(url) ?: return + val seerrUser = target.seerrUser(authMethod, username, password) + seerrServerDao.addUser(seerrUser) + try { + seerrApi.update(target.server.url, null) + val userConfig = seerrLogin(seerrApi.api, authMethod, username, password) + set(target.server, seerrUser, userConfig) + } catch (ex: Exception) { + Timber.w( + ex, + "Seerr login to %s failed — credentials stored, will retry on next start", + target.server.url, + ) + error(target.server, seerrUser, ex) } } + suspend fun findExistingForCurrentJellyfinUser(): SeerrServer? { + val jellyfinUser = serverRepository.currentUser ?: return null + val seerrUser = + seerrServerDao + .getUsersByJellyfinUser(jellyfinUser.rowId) + .lastOrNull() ?: return null + return seerrServerDao.getServer(seerrUser.serverId)?.server + } + + // Insert SeerrServer + SeerrUser with no password so URL + username appear pre-filled in + // the Add Seerr Server dialog; the user enters the password manually. + suspend fun prefillFromPlugin( + url: String, + authMethod: SeerrAuthMethod, + username: String, + ) { + val target = ensureServerAndCurrentUser(url) ?: return + if (seerrServerDao.getUser(target.server.id, target.jellyfinUser.rowId) != null) return + seerrServerDao.addUser(target.seerrUser(authMethod, username)) + } + suspend fun addAndChangeServer( url: String, authMethod: SeerrAuthMethod, username: String, password: String, ) { - var server = seerrServerDao.getServer(url) - if (server == null) { - seerrServerDao.addServer(SeerrServer(url = url)) - server = seerrServerDao.getServer(url) - } - server?.server?.let { server -> - serverRepository.currentUser?.let { jellyfinUser -> - // TODO Need to update server early so that cookies are saved - seerrApi.update(server.url, null) - val userConfig = seerrLogin(seerrApi.api, authMethod, username, password) - - val user = - SeerrUser( - jellyfinUserRowId = jellyfinUser.rowId, - serverId = server.id, - authMethod = authMethod, - username = username, - password = password, - credential = null, - ) - seerrServerDao.addUser(user) - set(server, user, userConfig) - } - } + val target = ensureServerAndCurrentUser(url) ?: return + + // TODO Need to update server early so that cookies are saved + seerrApi.update(target.server.url, null) + val userConfig = seerrLogin(seerrApi.api, authMethod, username, password) + val user = target.seerrUser(authMethod, username, password) + seerrServerDao.addUser(user) + set(target.server, user, userConfig) } suspend fun testConnection( 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 794e69816..f6a43a25d 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,6 +1,7 @@ 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 kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -25,11 +26,12 @@ class ServerPluginApi private val json = Json { - ignoreUnknownKeys = false + ignoreUnknownKeys = true } companion object { private const val HOME_CONFIG_PATH = "homesettings" + private const val SEERR_CONFIG_PATH = "seerrsettings" } suspend fun public(): Boolean { @@ -63,4 +65,25 @@ class ServerPluginApi } } } + + @OptIn(ExperimentalSerializationApi::class) + suspend fun fetchSeerrSettings(): SeerrPluginSettings? { + val url = createUrl(SEERR_CONFIG_PATH) ?: return null + val request = + Request + .Builder() + .url(url) + .get() + .build() + return okHttpClient.newCall(request).execute().use { res -> + if (res.isSuccessful) { + json.decodeFromStream(res.body.byteStream()) + } else if (res.code == 404) { + Timber.d("fetchSeerrSettings: plugin not configured (404)") + null + } else { + throw ApiClientException(res.code.toString() + " " + res.body.string()) + } + } + } } 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 9b35abcb3..1bc43b3a9 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 @@ -10,6 +10,10 @@ import com.github.damontecres.wholphin.data.SeerrServerDao 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.SeerrPluginLoginType +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrUser +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.WholphinDispatchers @@ -77,45 +81,112 @@ class UserSwitchListener 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) - } - } + seerrServerRepository.consumeAndClearPasswordsIfRequested(user.rowId) + launchIO { restoreOrAutoSetupSeerr(user) } + } + } + + private suspend fun restoreOrAutoSetupSeerr(user: JellyfinUser) { + val existing = + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .lastOrNull() + ?: return tryAutoSetupFromPlugin(user) + val server = seerrServerDao.getServer(existing.serverId)?.server ?: return + val effective = mergeStashedPassword(existing, server) + if (effective.authMethod != SeerrAuthMethod.API_KEY && effective.password.isNullOrBlank()) { + Timber.i("Seerr entry for %s has no password yet; skipping auto-login", server.url) + return + } + try { + seerrApi.update(server.url, effective.credential) + val userConfig = + if (effective.authMethod == SeerrAuthMethod.API_KEY) { + seerrApi.api.usersApi.authMeGet() + } else { + seerrLogin( + seerrApi.api, + effective.authMethod, + effective.username, + effective.password, + ) + } + seerrServerRepository.set(server, effective, userConfig) + } catch (ex: Exception) { + Timber.w(ex, "Seerr login to %s failed - credentials kept, will retry on next start", server.url) + seerrServerRepository.error(server, effective, ex) + } + } + + private suspend fun mergeStashedPassword( + existing: SeerrUser, + server: SeerrServer, + ): SeerrUser { + if (existing.authMethod != SeerrAuthMethod.JELLYFIN) return existing + val stashed = seerrServerRepository.consumeJellyfinPassword() + if (stashed.isNullOrBlank() || stashed == existing.password) return existing + val updated = existing.copy(password = stashed) + seerrServerDao.addUser(updated) + Timber.i("Updated Seerr password for %s from fresh login", server.url) + return updated + } + + private suspend fun tryAutoSetupFromPlugin(user: JellyfinUser) { + val settings = + try { + serverPluginApi.fetchSeerrSettings() + } catch (ex: Exception) { + Timber.w(ex, "Failed to fetch seerr settings from server plugin") + return + } ?: return + val url = settings.serverUrl + if (!url.isNotNullOrBlank()) return + val login = settings.login ?: return + try { + when (login.type) { + SeerrPluginLoginType.API_KEY -> { + val key = login.apiKey + if (!key.isNotNullOrBlank()) return + Timber.i("Auto-setup Seerr via API key from plugin") + seerrServerRepository.addAndChangeServer(url, key) + } + + SeerrPluginLoginType.LOCAL -> { + val username = login.local?.username + val password = login.local?.password + if (!username.isNotNullOrBlank() || !password.isNotNullOrBlank()) return + Timber.i("Auto-setup Seerr via local login from plugin") + seerrServerRepository.addAndChangeServer(url, SeerrAuthMethod.LOCAL, username, password) + } + + SeerrPluginLoginType.JELLYFIN -> { + val jf = login.jellyfin ?: return + if (jf.useCurrentUser) { + val username = user.name + if (!username.isNotNullOrBlank()) return + val password = seerrServerRepository.consumeJellyfinPassword() + if (password.isNullOrBlank()) { + Timber.i("Pre-filling Seerr URL + username for %s (password required from user)", username) + seerrServerRepository.prefillFromPlugin(url, SeerrAuthMethod.JELLYFIN, username) + } else { + Timber.i("Auto-setup Seerr via Jellyfin (useCurrentUser=%s)", username) + seerrServerRepository.persistAndTryLogin(url, SeerrAuthMethod.JELLYFIN, username, password) } + return + } + val username = jf.username + val password = jf.password + if (!username.isNotNullOrBlank() || !password.isNotNullOrBlank()) return + Timber.i("Auto-setup Seerr via Jellyfin (explicit creds) from plugin") + seerrServerRepository.addAndChangeServer(url, SeerrAuthMethod.JELLYFIN, username, password) + } + + SeerrPluginLoginType.NONE -> { + Timber.d("Seerr plugin settings present but login type is None") } } + } catch (ex: Exception) { + Timber.w(ex, "Seerr auto-setup from plugin failed for %s", url) } + } } 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 982e2537d..48042a550 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 @@ -640,6 +640,7 @@ fun PreferencesContent( SeerrDialogMode.Add -> { val currentUser by seerrVm.currentUser.collectAsState(null) val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) + val prefilledUrl by seerrVm.prefilledServerUrl.collectAsState() val serverAddedMessage = stringResource(R.string.seerr_server_added) LaunchedEffect(status) { if (status == LoadingState.Success) { @@ -649,6 +650,7 @@ fun PreferencesContent( } AddSeerServerDialog( currentUsername = currentUser?.name, + currentUrl = prefilledUrl, status = status, onSubmit = seerrVm::submitServer, onResetStatus = seerrVm::resetStatus, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index 8b8c159aa..d53376729 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -2,12 +2,14 @@ package com.github.damontecres.wholphin.ui.setup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.launchDefault @@ -49,6 +51,7 @@ class SwitchUserViewModel val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, val imageUrlService: ImageUrlService, + val seerrServerRepository: SeerrServerRepository, @Assisted val server: JellyfinServer, ) : ViewModel() { @AssistedFactory @@ -150,6 +153,9 @@ class SwitchUserViewModel username = username, password = password, ) + if (BuildConfig.DISCOVER_ENABLED) { + seerrServerRepository.stashJellyfinPasswordForAutoSetup(password) + } val current = serverRepository.changeUser(server.url, authenticationResult, existingUser) setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) @@ -192,6 +198,9 @@ class SwitchUserViewModel val authenticationResult by api.userApi.authenticateWithQuickConnect( QuickConnectDto(secret = quickConnectStatus.secret), ) + if (BuildConfig.DISCOVER_ENABLED) { + seerrServerRepository.requestClearJellyfinPasswordOnNextSwitch() + } val current = serverRepository.changeUser( server.url, 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 5fa8c6671..e6cca7559 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 @@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.util.LoadingState fun AddSeerrServerApiKey( onSubmit: (url: String, apiKey: String) -> Unit, status: LoadingState, + initialUrl: String = "", modifier: Modifier = Modifier, ) { var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) } @@ -54,7 +55,7 @@ fun AddSeerrServerApiKey( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf("") } + var url by remember(initialUrl) { mutableStateOf(initialUrl) } var apiKey by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } @@ -155,6 +156,7 @@ fun AddSeerrServerUsername( onSubmit: (url: String, username: String, password: String) -> Unit, username: String, status: LoadingState, + initialUrl: String = "", modifier: Modifier = Modifier, ) { var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) } @@ -166,7 +168,7 @@ fun AddSeerrServerUsername( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf("") } + var url by remember(initialUrl) { mutableStateOf(initialUrl) } var username by remember { mutableStateOf(username) } var password by remember { mutableStateOf("") } 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 d84b98db6..c933dcb9b 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 @@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.util.LoadingState @Composable fun AddSeerServerDialog( currentUsername: String?, + currentUrl: String?, status: LoadingState, onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, onResetStatus: () -> Unit, @@ -46,6 +47,7 @@ fun AddSeerServerDialog( onSubmit.invoke(url, username, password, auth) }, username = currentUsername ?: "", + initialUrl = currentUrl ?: "", status = status, modifier = Modifier.widthIn(min = 320.dp), ) @@ -60,6 +62,7 @@ fun AddSeerServerDialog( onSubmit = { url, apiKey -> onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY) }, + initialUrl = currentUrl ?: "", status = status, modifier = Modifier.widthIn(min = 320.dp), ) 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 773f6c745..865de6f2e 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,6 +3,7 @@ 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.BuildConfig import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.SeerrAuthMethod @@ -34,6 +35,18 @@ class SwitchSeerrViewModel val serverConnectionStatus = MutableStateFlow(LoadingState.Pending) + val prefilledServerUrl = MutableStateFlow(null) + + init { + if (BuildConfig.DISCOVER_ENABLED) { + viewModelScope.launchIO { + prefilledServerUrl.update { + seerrServerRepository.findExistingForCurrentJellyfinUser()?.url + } + } + } + } + fun submitServer( url: String, username: String, From 01a438dc17750b5fa82063de53a896af709e1dfc Mon Sep 17 00:00:00 2001 From: Mario Reisinger Date: Tue, 23 Jun 2026 20:24:44 +0200 Subject: [PATCH 2/3] Remove automatic Seerr Jellyfin plugin auth --- .../data/model/SeerrServerPluginSettings.kt | 11 -- .../services/SeerrServerRepository.kt | 101 +++++------------- .../wholphin/services/UserSwitchListener.kt | 81 +++++--------- .../ui/preferences/PreferencesContent.kt | 3 + .../wholphin/ui/setup/SwitchUserViewModel.kt | 9 -- .../ui/setup/seerr/SwitchSeerrViewModel.kt | 14 +-- 6 files changed, 62 insertions(+), 157 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServerPluginSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServerPluginSettings.kt index eb98246e7..44ae656c5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServerPluginSettings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServerPluginSettings.kt @@ -11,20 +11,10 @@ enum class SeerrPluginLoginType { @SerialName("ApiKey") API_KEY, - @SerialName("Jellyfin") - JELLYFIN, - @SerialName("Local") LOCAL, } -@Serializable -data class SeerrPluginJellyfinLogin( - val useCurrentUser: Boolean = false, - val username: String? = null, - val password: String? = null, -) - @Serializable data class SeerrPluginLocalLogin( val username: String? = null, @@ -35,7 +25,6 @@ data class SeerrPluginLocalLogin( data class SeerrPluginLogin( val type: SeerrPluginLoginType = SeerrPluginLoginType.NONE, val apiKey: String? = null, - val jellyfin: SeerrPluginJellyfinLogin? = null, val local: SeerrPluginLocalLogin? = 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 d565efb75..3da1735f6 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 @@ -14,7 +14,6 @@ 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.flow.Flow @@ -25,9 +24,6 @@ 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 java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.seconds @@ -48,39 +44,6 @@ class SeerrServerRepository MutableStateFlow(SeerrConnectionStatus.NotConfigured) val connection: StateFlow = _connection - // Buffer for the Jellyfin password from the most recent password-based login — - // consumed once by the auto-setup flow if the plugin asks for useCurrentUser. - // Memory-only; we never want to persist a Jellyfin cleartext password. - private val pendingJellyfinPassword = AtomicReference(null) - - // Signal set by the QuickConnect login flow before changeUser, so the upcoming - // switchUser knows to wipe stale Jellyfin passwords from any SeerrUser rows. - private val clearJellyfinPasswordRequested = AtomicBoolean(false) - - fun stashJellyfinPasswordForAutoSetup(password: String?) { - pendingJellyfinPassword.set(password) - } - - fun consumeJellyfinPassword(): String? = pendingJellyfinPassword.getAndSet(null) - - fun requestClearJellyfinPasswordOnNextSwitch() { - clearJellyfinPasswordRequested.set(true) - } - - suspend fun consumeAndClearPasswordsIfRequested(jellyfinUserRowId: Int) { - if (!clearJellyfinPasswordRequested.getAndSet(false)) return - seerrServerDao - .getUsersByJellyfinUser(jellyfinUserRowId) - .filter { it.authMethod == SeerrAuthMethod.JELLYFIN && it.password.isNotNullOrBlank() } - .forEach { user -> - Timber.i( - "Clearing stored Jellyfin password for SeerrUser %s after QuickConnect login", - user.username, - ) - seerrServerDao.addUser(user.copy(password = null)) - } - } - val current: Flow = _connection.map { (it as? SeerrConnectionStatus.Success)?.current } val currentServer: Flow = @@ -88,6 +51,7 @@ class SeerrServerRepository val currentUser: Flow = connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } val currentUserId: Flow = current.map { it?.config?.id } + private val pluginPrefilledServerUrl = MutableStateFlow(null) private data class SetupTarget( val server: SeerrServer, @@ -116,6 +80,7 @@ class SeerrServerRepository fun clear() { _connection.update { SeerrConnectionStatus.NotConfigured } + pluginPrefilledServerUrl.update { null } seerrApi.update("", null) } @@ -151,45 +116,24 @@ class SeerrServerRepository val user = target.seerrUser(SeerrAuthMethod.API_KEY, credential = apiKey) seerrServerDao.addUser(user) set(target.server, user, userConfig) + pluginPrefilledServerUrl.update { null } } + private suspend fun ensureServer(url: String): SeerrServer? = + seerrServerDao.getServer(url)?.server + ?: run { + seerrServerDao.addServer(SeerrServer(url = url)) + seerrServerDao.getServer(url)?.server + } + private suspend fun ensureServerAndCurrentUser(url: String): SetupTarget? { val server = - seerrServerDao.getServer(url)?.server - ?: run { - seerrServerDao.addServer(SeerrServer(url = url)) - seerrServerDao.getServer(url)?.server - } + ensureServer(url) ?: return null val jellyfinUser = serverRepository.currentUser ?: return null return SetupTarget(server, jellyfinUser) } - // Persists credentials *before* the login attempt — if Seerr is down, the existing-user - // branch in [UserSwitchListener] retries on the next app start. - suspend fun persistAndTryLogin( - url: String, - authMethod: SeerrAuthMethod, - username: String, - password: String, - ) { - val target = ensureServerAndCurrentUser(url) ?: return - val seerrUser = target.seerrUser(authMethod, username, password) - seerrServerDao.addUser(seerrUser) - try { - seerrApi.update(target.server.url, null) - val userConfig = seerrLogin(seerrApi.api, authMethod, username, password) - set(target.server, seerrUser, userConfig) - } catch (ex: Exception) { - Timber.w( - ex, - "Seerr login to %s failed — credentials stored, will retry on next start", - target.server.url, - ) - error(target.server, seerrUser, ex) - } - } - suspend fun findExistingForCurrentJellyfinUser(): SeerrServer? { val jellyfinUser = serverRepository.currentUser ?: return null val seerrUser = @@ -199,18 +143,20 @@ class SeerrServerRepository return seerrServerDao.getServer(seerrUser.serverId)?.server } - // Insert SeerrServer + SeerrUser with no password so URL + username appear pre-filled in - // the Add Seerr Server dialog; the user enters the password manually. - suspend fun prefillFromPlugin( - url: String, - authMethod: SeerrAuthMethod, - username: String, - ) { - val target = ensureServerAndCurrentUser(url) ?: return - if (seerrServerDao.getUser(target.server.id, target.jellyfinUser.rowId) != null) return - seerrServerDao.addUser(target.seerrUser(authMethod, username)) + suspend fun prefillFromPlugin(url: String) { + val server = ensureServer(url) ?: return + pluginPrefilledServerUrl.update { server.url } } + suspend fun findPrefillServerUrlForCurrentJellyfinUser(): String? = + findExistingForCurrentJellyfinUser()?.url + ?: pluginPrefilledServerUrl.value + ?: seerrServerDao + .getServers() + .lastOrNull() + ?.server + ?.url + suspend fun addAndChangeServer( url: String, authMethod: SeerrAuthMethod, @@ -225,6 +171,7 @@ class SeerrServerRepository val user = target.seerrUser(authMethod, username, password) seerrServerDao.addUser(user) set(target.server, user, userConfig) + pluginPrefilledServerUrl.update { null } } suspend fun testConnection( 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 1bc43b3a9..3567b93e7 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 @@ -6,13 +6,12 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import androidx.lifecycle.lifecycleScope import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.data.SeerrServerDao 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.SeerrPluginLoginType -import com.github.damontecres.wholphin.data.model.SeerrServer -import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -81,7 +80,6 @@ class UserSwitchListener homeSettingsService.loadCurrentSettings(user) } if (BuildConfig.DISCOVER_ENABLED) { - seerrServerRepository.consumeAndClearPasswordsIfRequested(user.rowId) launchIO { restoreOrAutoSetupSeerr(user) } } } @@ -91,47 +89,29 @@ class UserSwitchListener seerrServerDao .getUsersByJellyfinUser(user.rowId) .lastOrNull() - ?: return tryAutoSetupFromPlugin(user) + ?: return tryAutoSetupFromPlugin() val server = seerrServerDao.getServer(existing.serverId)?.server ?: return - val effective = mergeStashedPassword(existing, server) - if (effective.authMethod != SeerrAuthMethod.API_KEY && effective.password.isNullOrBlank()) { - Timber.i("Seerr entry for %s has no password yet; skipping auto-login", server.url) - return - } try { - seerrApi.update(server.url, effective.credential) + seerrApi.update(server.url, existing.credential) val userConfig = - if (effective.authMethod == SeerrAuthMethod.API_KEY) { + if (existing.authMethod == SeerrAuthMethod.API_KEY) { seerrApi.api.usersApi.authMeGet() } else { seerrLogin( seerrApi.api, - effective.authMethod, - effective.username, - effective.password, + existing.authMethod, + existing.username, + existing.password, ) } - seerrServerRepository.set(server, effective, userConfig) + seerrServerRepository.set(server, existing, userConfig) } catch (ex: Exception) { Timber.w(ex, "Seerr login to %s failed - credentials kept, will retry on next start", server.url) - seerrServerRepository.error(server, effective, ex) + seerrServerRepository.error(server, existing, ex) } } - private suspend fun mergeStashedPassword( - existing: SeerrUser, - server: SeerrServer, - ): SeerrUser { - if (existing.authMethod != SeerrAuthMethod.JELLYFIN) return existing - val stashed = seerrServerRepository.consumeJellyfinPassword() - if (stashed.isNullOrBlank() || stashed == existing.password) return existing - val updated = existing.copy(password = stashed) - seerrServerDao.addUser(updated) - Timber.i("Updated Seerr password for %s from fresh login", server.url) - return updated - } - - private suspend fun tryAutoSetupFromPlugin(user: JellyfinUser) { + private suspend fun tryAutoSetupFromPlugin() { val settings = try { serverPluginApi.fetchSeerrSettings() @@ -141,7 +121,12 @@ class UserSwitchListener } ?: return val url = settings.serverUrl if (!url.isNotNullOrBlank()) return - val login = settings.login ?: return + val login = settings.login + if (login == null) { + Timber.i("Pre-filling Seerr URL from plugin") + seerrServerRepository.prefillFromPlugin(url) + return + } try { when (login.type) { SeerrPluginLoginType.API_KEY -> { @@ -159,33 +144,21 @@ class UserSwitchListener seerrServerRepository.addAndChangeServer(url, SeerrAuthMethod.LOCAL, username, password) } - SeerrPluginLoginType.JELLYFIN -> { - val jf = login.jellyfin ?: return - if (jf.useCurrentUser) { - val username = user.name - if (!username.isNotNullOrBlank()) return - val password = seerrServerRepository.consumeJellyfinPassword() - if (password.isNullOrBlank()) { - Timber.i("Pre-filling Seerr URL + username for %s (password required from user)", username) - seerrServerRepository.prefillFromPlugin(url, SeerrAuthMethod.JELLYFIN, username) - } else { - Timber.i("Auto-setup Seerr via Jellyfin (useCurrentUser=%s)", username) - seerrServerRepository.persistAndTryLogin(url, SeerrAuthMethod.JELLYFIN, username, password) - } - return - } - val username = jf.username - val password = jf.password - if (!username.isNotNullOrBlank() || !password.isNotNullOrBlank()) return - Timber.i("Auto-setup Seerr via Jellyfin (explicit creds) from plugin") - seerrServerRepository.addAndChangeServer(url, SeerrAuthMethod.JELLYFIN, username, password) - } - SeerrPluginLoginType.NONE -> { - Timber.d("Seerr plugin settings present but login type is None") + Timber.i("Pre-filling Seerr URL from plugin") + seerrServerRepository.prefillFromPlugin(url) } } + } catch (ex: ClientException) { + seerrServerRepository.prefillFromPlugin(url) + Timber.w( + ex, + "Seerr auto-setup from plugin failed for %s with HTTP %s. Check plugin Seerr credentials and auth type.", + url, + ex.statusCode, + ) } catch (ex: Exception) { + seerrServerRepository.prefillFromPlugin(url) Timber.w(ex, "Seerr auto-setup from plugin failed for %s", url) } } 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 48042a550..782f2ac75 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 @@ -642,6 +642,9 @@ fun PreferencesContent( val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) val prefilledUrl by seerrVm.prefilledServerUrl.collectAsState() val serverAddedMessage = stringResource(R.string.seerr_server_added) + LaunchedEffect(Unit) { + seerrVm.refreshPrefilledServerUrl() + } LaunchedEffect(status) { if (status == LoadingState.Success) { Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index d53376729..8b8c159aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -2,14 +2,12 @@ package com.github.damontecres.wholphin.ui.setup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.launchDefault @@ -51,7 +49,6 @@ class SwitchUserViewModel val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, val imageUrlService: ImageUrlService, - val seerrServerRepository: SeerrServerRepository, @Assisted val server: JellyfinServer, ) : ViewModel() { @AssistedFactory @@ -153,9 +150,6 @@ class SwitchUserViewModel username = username, password = password, ) - if (BuildConfig.DISCOVER_ENABLED) { - seerrServerRepository.stashJellyfinPasswordForAutoSetup(password) - } val current = serverRepository.changeUser(server.url, authenticationResult, existingUser) setupNavigationManager.navigateTo(SetupDestination.AppContent(current)) @@ -198,9 +192,6 @@ class SwitchUserViewModel val authenticationResult by api.userApi.authenticateWithQuickConnect( QuickConnectDto(secret = quickConnectStatus.secret), ) - if (BuildConfig.DISCOVER_ENABLED) { - seerrServerRepository.requestClearJellyfinPasswordOnNextSwitch() - } val current = serverRepository.changeUser( server.url, 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 865de6f2e..457ef3933 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 @@ -37,12 +37,14 @@ class SwitchSeerrViewModel val prefilledServerUrl = MutableStateFlow(null) - init { - if (BuildConfig.DISCOVER_ENABLED) { - viewModelScope.launchIO { - prefilledServerUrl.update { - seerrServerRepository.findExistingForCurrentJellyfinUser()?.url - } + fun refreshPrefilledServerUrl() { + if (!BuildConfig.DISCOVER_ENABLED) { + prefilledServerUrl.update { null } + return + } + viewModelScope.launchIO { + prefilledServerUrl.update { + seerrServerRepository.findPrefillServerUrlForCurrentJellyfinUser() } } } From 324eaebdab7fa31d61566c5fa2f80315d33a2c12 Mon Sep 17 00:00:00 2001 From: Mario Reisinger Date: Sun, 28 Jun 2026 18:46:31 +0200 Subject: [PATCH 3/3] Address Seerr plugin review feedback - Model plugin Seerr login as a sealed interface - Move plugin Seerr restore and auto-setup into SeerrServerRepository - Fetch plugin prefill URL on demand and persist it without auto-login - Load Seerr dialog prefill URL via DataLoadingState - Keep AddSeerrServerDialog URL non-nullable - Remove obsolete ViewModel and listener state from the refactor --- .../data/model/SeerrServerPluginSettings.kt | 26 ++--- .../services/SeerrServerRepository.kt | 96 +++++++++++++++++-- .../wholphin/services/UserSwitchListener.kt | 88 +---------------- .../ui/preferences/PreferencesContent.kt | 37 +++++-- .../ui/setup/seerr/AddSeerrServerDialog.kt | 6 +- .../ui/setup/seerr/SwitchSeerrViewModel.kt | 20 ++-- 6 files changed, 142 insertions(+), 131 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServerPluginSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServerPluginSettings.kt index 44ae656c5..1ff7212b1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServerPluginSettings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServerPluginSettings.kt @@ -4,28 +4,28 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -enum class SeerrPluginLoginType { +sealed interface SeerrPluginLogin { @SerialName("None") - NONE, + @Serializable + data object None : SeerrPluginLogin @SerialName("ApiKey") - API_KEY, + @Serializable + data class ApiKey( + val apiKey: String, + ) : SeerrPluginLogin @SerialName("Local") - LOCAL, + @Serializable + data class Local( + val local: SeerrPluginLocalLogin, + ) : SeerrPluginLogin } @Serializable data class SeerrPluginLocalLogin( - val username: String? = null, - val password: String? = null, -) - -@Serializable -data class SeerrPluginLogin( - val type: SeerrPluginLoginType = SeerrPluginLoginType.NONE, - val apiKey: String? = null, - val local: SeerrPluginLocalLogin? = null, + val username: String, + val password: String, ) @Serializable 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 3da1735f6..32894a4d6 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 @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest import com.github.damontecres.wholphin.api.seerr.model.PublicSettings @@ -10,10 +11,12 @@ 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.SeerrPermission +import com.github.damontecres.wholphin.data.model.SeerrPluginLogin 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.flow.Flow @@ -24,6 +27,7 @@ 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 @@ -38,6 +42,7 @@ class SeerrServerRepository private val seerrApi: SeerrApi, private val seerrServerDao: SeerrServerDao, private val serverRepository: ServerRepository, + private val serverPluginApi: ServerPluginApi, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { private val _connection = @@ -51,7 +56,6 @@ class SeerrServerRepository val currentUser: Flow = connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } val currentUserId: Flow = current.map { it?.config?.id } - private val pluginPrefilledServerUrl = MutableStateFlow(null) private data class SetupTarget( val server: SeerrServer, @@ -80,7 +84,6 @@ class SeerrServerRepository fun clear() { _connection.update { SeerrConnectionStatus.NotConfigured } - pluginPrefilledServerUrl.update { null } seerrApi.update("", null) } @@ -116,7 +119,6 @@ class SeerrServerRepository val user = target.seerrUser(SeerrAuthMethod.API_KEY, credential = apiKey) seerrServerDao.addUser(user) set(target.server, user, userConfig) - pluginPrefilledServerUrl.update { null } } private suspend fun ensureServer(url: String): SeerrServer? = @@ -134,7 +136,7 @@ class SeerrServerRepository return SetupTarget(server, jellyfinUser) } - suspend fun findExistingForCurrentJellyfinUser(): SeerrServer? { + private suspend fun findExistingForCurrentJellyfinUser(): SeerrServer? { val jellyfinUser = serverRepository.currentUser ?: return null val seerrUser = seerrServerDao @@ -143,19 +145,94 @@ class SeerrServerRepository return seerrServerDao.getServer(seerrUser.serverId)?.server } - suspend fun prefillFromPlugin(url: String) { - val server = ensureServer(url) ?: return - pluginPrefilledServerUrl.update { server.url } + private suspend fun prefillFromPlugin(url: String) { + ensureServer(url) } - suspend fun findPrefillServerUrlForCurrentJellyfinUser(): String? = + suspend fun findPrefillServerUrlForCurrentJellyfinUser(): String = findExistingForCurrentJellyfinUser()?.url - ?: pluginPrefilledServerUrl.value + ?: fetchPluginSeerrSettings()?.serverUrl?.takeIf { it.isNotNullOrBlank() }?.also { prefillFromPlugin(it) } ?: seerrServerDao .getServers() .lastOrNull() ?.server ?.url + ?: "" + + suspend fun restoreOrAutoSetupForCurrentUser(user: JellyfinUser) { + val existing = + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .lastOrNull() + ?: return tryAutoSetupFromPlugin() + val server = seerrServerDao.getServer(existing.serverId)?.server ?: return + try { + seerrApi.update(server.url, existing.credential) + val userConfig = + if (existing.authMethod == SeerrAuthMethod.API_KEY) { + seerrApi.api.usersApi.authMeGet() + } else { + seerrLogin( + seerrApi.api, + existing.authMethod, + existing.username, + existing.password, + ) + } + set(server, existing, userConfig) + } catch (ex: Exception) { + Timber.w(ex, "Seerr login to %s failed - credentials kept, will retry on next start", server.url) + error(server, existing, ex) + } + } + + private suspend fun fetchPluginSeerrSettings() = + try { + serverPluginApi.fetchSeerrSettings() + } catch (ex: Exception) { + Timber.w(ex, "Failed to fetch seerr settings from server plugin") + null + } + + private suspend fun tryAutoSetupFromPlugin() { + val settings = fetchPluginSeerrSettings() ?: return + val url = settings.serverUrl + if (!url.isNotNullOrBlank()) return + try { + when (val login = settings.login ?: SeerrPluginLogin.None) { + is SeerrPluginLogin.ApiKey -> { + Timber.i("Auto-setup Seerr via API key from plugin") + addAndChangeServer(url, login.apiKey) + } + + is SeerrPluginLogin.Local -> { + Timber.i("Auto-setup Seerr via local login from plugin") + addAndChangeServer( + url, + SeerrAuthMethod.LOCAL, + login.local.username, + login.local.password, + ) + } + + SeerrPluginLogin.None -> { + Timber.i("Pre-filling Seerr URL from plugin") + prefillFromPlugin(url) + } + } + } catch (ex: ClientException) { + prefillFromPlugin(url) + Timber.w( + ex, + "Seerr auto-setup from plugin failed for %s with HTTP %s. Check plugin Seerr credentials and auth type.", + url, + ex.statusCode, + ) + } catch (ex: Exception) { + prefillFromPlugin(url) + Timber.w(ex, "Seerr auto-setup from plugin failed for %s", url) + } + } suspend fun addAndChangeServer( url: String, @@ -171,7 +248,6 @@ class SeerrServerRepository val user = target.seerrUser(authMethod, username, password) seerrServerDao.addUser(user) set(target.server, user, userConfig) - pluginPrefilledServerUrl.update { null } } suspend fun testConnection( 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 3567b93e7..648a8a39a 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 @@ -6,13 +6,8 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import androidx.lifecycle.lifecycleScope import com.github.damontecres.wholphin.BuildConfig -import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException -import com.github.damontecres.wholphin.data.SeerrServerDao 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.SeerrPluginLoginType -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.WholphinDispatchers @@ -34,8 +29,6 @@ class UserSwitchListener @param:ActivityContext private val context: Context, private val serverRepository: ServerRepository, private val seerrServerRepository: SeerrServerRepository, - private val seerrServerDao: SeerrServerDao, - private val seerrApi: SeerrApi, private val homeSettingsService: HomeSettingsService, private val serverPluginApi: ServerPluginApi, ) { @@ -80,86 +73,7 @@ class UserSwitchListener homeSettingsService.loadCurrentSettings(user) } if (BuildConfig.DISCOVER_ENABLED) { - launchIO { restoreOrAutoSetupSeerr(user) } + launchIO { seerrServerRepository.restoreOrAutoSetupForCurrentUser(user) } } } - - private suspend fun restoreOrAutoSetupSeerr(user: JellyfinUser) { - val existing = - seerrServerDao - .getUsersByJellyfinUser(user.rowId) - .lastOrNull() - ?: return tryAutoSetupFromPlugin() - val server = seerrServerDao.getServer(existing.serverId)?.server ?: return - try { - seerrApi.update(server.url, existing.credential) - val userConfig = - if (existing.authMethod == SeerrAuthMethod.API_KEY) { - seerrApi.api.usersApi.authMeGet() - } else { - seerrLogin( - seerrApi.api, - existing.authMethod, - existing.username, - existing.password, - ) - } - seerrServerRepository.set(server, existing, userConfig) - } catch (ex: Exception) { - Timber.w(ex, "Seerr login to %s failed - credentials kept, will retry on next start", server.url) - seerrServerRepository.error(server, existing, ex) - } - } - - private suspend fun tryAutoSetupFromPlugin() { - val settings = - try { - serverPluginApi.fetchSeerrSettings() - } catch (ex: Exception) { - Timber.w(ex, "Failed to fetch seerr settings from server plugin") - return - } ?: return - val url = settings.serverUrl - if (!url.isNotNullOrBlank()) return - val login = settings.login - if (login == null) { - Timber.i("Pre-filling Seerr URL from plugin") - seerrServerRepository.prefillFromPlugin(url) - return - } - try { - when (login.type) { - SeerrPluginLoginType.API_KEY -> { - val key = login.apiKey - if (!key.isNotNullOrBlank()) return - Timber.i("Auto-setup Seerr via API key from plugin") - seerrServerRepository.addAndChangeServer(url, key) - } - - SeerrPluginLoginType.LOCAL -> { - val username = login.local?.username - val password = login.local?.password - if (!username.isNotNullOrBlank() || !password.isNotNullOrBlank()) return - Timber.i("Auto-setup Seerr via local login from plugin") - seerrServerRepository.addAndChangeServer(url, SeerrAuthMethod.LOCAL, username, password) - } - - SeerrPluginLoginType.NONE -> { - Timber.i("Pre-filling Seerr URL from plugin") - seerrServerRepository.prefillFromPlugin(url) - } - } - } catch (ex: ClientException) { - seerrServerRepository.prefillFromPlugin(url) - Timber.w( - ex, - "Seerr auto-setup from plugin failed for %s with HTTP %s. Check plugin Seerr credentials and auth type.", - url, - ex.statusCode, - ) - } catch (ex: Exception) { - seerrServerRepository.prefillFromPlugin(url) - Timber.w(ex, "Seerr auto-setup from plugin failed for %s", url) - } - } } 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 782f2ac75..54552fbe9 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 @@ -651,14 +651,35 @@ fun PreferencesContent( seerrDialogMode = SeerrDialogMode.None } } - AddSeerServerDialog( - currentUsername = currentUser?.name, - currentUrl = prefilledUrl, - status = status, - onSubmit = seerrVm::submitServer, - onResetStatus = seerrVm::resetStatus, - onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, - ) + when (val urlState = prefilledUrl) { + DataLoadingState.Pending, + DataLoadingState.Loading, + -> { + LoadingPage() + } + + is DataLoadingState.Success -> { + AddSeerServerDialog( + currentUsername = currentUser?.name, + currentUrl = urlState.data, + status = status, + onSubmit = seerrVm::submitServer, + onResetStatus = seerrVm::resetStatus, + onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, + ) + } + + is DataLoadingState.Error -> { + AddSeerServerDialog( + currentUsername = currentUser?.name, + currentUrl = "", + status = status, + onSubmit = seerrVm::submitServer, + onResetStatus = seerrVm::resetStatus, + onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, + ) + } + } } is SeerrDialogMode.Error -> { 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 c933dcb9b..d519a83c4 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 @@ -22,7 +22,7 @@ import com.github.damontecres.wholphin.util.LoadingState @Composable fun AddSeerServerDialog( currentUsername: String?, - currentUrl: String?, + currentUrl: String, status: LoadingState, onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, onResetStatus: () -> Unit, @@ -47,7 +47,7 @@ fun AddSeerServerDialog( onSubmit.invoke(url, username, password, auth) }, username = currentUsername ?: "", - initialUrl = currentUrl ?: "", + initialUrl = currentUrl, status = status, modifier = Modifier.widthIn(min = 320.dp), ) @@ -62,7 +62,7 @@ fun AddSeerServerDialog( onSubmit = { url, apiKey -> onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY) }, - initialUrl = currentUrl ?: "", + initialUrl = currentUrl, status = status, modifier = Modifier.widthIn(min = 320.dp), ) 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 457ef3933..ff65a90bc 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,14 +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.BuildConfig 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.ui.launchIO import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -27,7 +26,6 @@ class SwitchSeerrViewModel constructor( @param:ApplicationContext private val context: Context, private val seerrServerRepository: SeerrServerRepository, - private val seerrService: SeerrService, private val serverRepository: ServerRepository, ) : ViewModel() { val currentUser = serverRepository.currentUserFlow @@ -35,16 +33,18 @@ class SwitchSeerrViewModel val serverConnectionStatus = MutableStateFlow(LoadingState.Pending) - val prefilledServerUrl = MutableStateFlow(null) + val prefilledServerUrl = MutableStateFlow>(DataLoadingState.Pending) fun refreshPrefilledServerUrl() { - if (!BuildConfig.DISCOVER_ENABLED) { - prefilledServerUrl.update { null } - return - } viewModelScope.launchIO { - prefilledServerUrl.update { - seerrServerRepository.findPrefillServerUrlForCurrentJellyfinUser() + prefilledServerUrl.update { DataLoadingState.Loading } + try { + prefilledServerUrl.update { + DataLoadingState.Success(seerrServerRepository.findPrefillServerUrlForCurrentJellyfinUser()) + } + } catch (ex: Exception) { + Timber.w(ex, "Failed to load Seerr prefilled URL") + prefilledServerUrl.update { DataLoadingState.Error(ex) } } } }