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..1ff7212b1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServerPluginSettings.kt @@ -0,0 +1,36 @@ +package com.github.damontecres.wholphin.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed interface SeerrPluginLogin { + @SerialName("None") + @Serializable + data object None : SeerrPluginLogin + + @SerialName("ApiKey") + @Serializable + data class ApiKey( + val apiKey: String, + ) : SeerrPluginLogin + + @SerialName("Local") + @Serializable + data class Local( + val local: SeerrPluginLocalLogin, + ) : SeerrPluginLogin +} + +@Serializable +data class SeerrPluginLocalLogin( + val username: String, + val password: String, +) + +@Serializable +data class SeerrPluginSettings( + val version: Int = 1, + val serverUrl: String? = null, + val login: SeerrPluginLogin? = null, +) 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..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,18 +1,22 @@ 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 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.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 @@ -23,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 @@ -37,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,6 +57,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,29 +113,124 @@ class SeerrServerRepository url: String, apiKey: String, ) { - var server = seerrServerDao.getServer(url) - if (server == null) { - seerrServerDao.addServer(SeerrServer(url = url)) - server = seerrServerDao.getServer(url) + 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 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 = + ensureServer(url) + ?: return null + val jellyfinUser = serverRepository.currentUser ?: return null + return SetupTarget(server, jellyfinUser) + } + + private 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 + } + + private suspend fun prefillFromPlugin(url: String) { + ensureServer(url) + } + + suspend fun findPrefillServerUrlForCurrentJellyfinUser(): String = + findExistingForCurrentJellyfinUser()?.url + ?: 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) } - 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, + } + + 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, ) - seerrServerDao.addUser(user) + } - seerrApi.update(server.url, apiKey) - val userConfig = seerrApi.api.usersApi.authMeGet() - set(server, user, userConfig) + 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) } } @@ -120,30 +240,14 @@ class SeerrServerRepository 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..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,10 +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.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.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.WholphinDispatchers @@ -31,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, ) { @@ -77,45 +73,7 @@ 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) - } - } - } - } + launchIO { seerrServerRepository.restoreOrAutoSetupForCurrentUser(user) } } } } 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..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 @@ -640,20 +640,46 @@ 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(Unit) { + seerrVm.refreshPrefilledServerUrl() + } LaunchedEffect(status) { if (status == LoadingState.Success) { Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show() seerrDialogMode = SeerrDialogMode.None } } - AddSeerServerDialog( - currentUsername = currentUser?.name, - status = status, - onSubmit = seerrVm::submitServer, - onResetStatus = seerrVm::resetStatus, - onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, - ) + 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/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..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,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..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 @@ -7,9 +7,9 @@ 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 @@ -26,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 @@ -34,6 +33,22 @@ class SwitchSeerrViewModel val serverConnectionStatus = MutableStateFlow(LoadingState.Pending) + val prefilledServerUrl = MutableStateFlow>(DataLoadingState.Pending) + + fun refreshPrefilledServerUrl() { + viewModelScope.launchIO { + 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) } + } + } + } + fun submitServer( url: String, username: String,