Auto-configure Discover from Wholphin server plugin (#2134)
## Description https://github.com/damontecres/jellyfin-plugin-wholphin/pull/3 added a new endpoint for Seerr configuration in Wholphin server plugin. This PR adds support for reading that configuration and either pre-filling the URL or automatically logging in. Some code was copied from #1418 (thanks @matschi95!), but that PR worked by inserting a DB record for the plugin provided server. Since the local DB was preferred, this meant plugin config changes wouldn't be reflected in the client. This PR instead attempts the connection and only updates the in-memory connection state, so user switching and app restarts will re-fetch from the plugin. If the server plugin is not installed or no Seerr server is configured, nothing in Wholphin changes. ### Related issues #598 This PR replaces #1418 ### Testing Emulator with different scenarios ## Screenshots N/A ## AI or LLM usage None
This commit is contained in:
parent
90a2d6d459
commit
f66746cf47
11 changed files with 454 additions and 160 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -9,20 +9,24 @@ import com.github.damontecres.wholphin.data.SeerrServerDao
|
|||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
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.SeerrPluginSettings
|
||||
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.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
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
|
||||
|
|
@ -39,26 +43,25 @@ class SeerrServerRepository
|
|||
private val serverRepository: ServerRepository,
|
||||
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
private val _connection =
|
||||
MutableStateFlow<SeerrConnectionStatus>(SeerrConnectionStatus.NotConfigured)
|
||||
val connection: StateFlow<SeerrConnectionStatus> = _connection
|
||||
private val _connection = MutableStateFlow<SeerrState>(SeerrState())
|
||||
val connection: StateFlow<SeerrState> = _connection
|
||||
|
||||
val current: Flow<CurrentSeerr?> =
|
||||
_connection.map { (it as? SeerrConnectionStatus.Success)?.current }
|
||||
_connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current }
|
||||
val currentServer: Flow<SeerrServer?> =
|
||||
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
|
||||
connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.server }
|
||||
val currentUser: Flow<SeerrUser?> =
|
||||
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
|
||||
connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.user }
|
||||
val currentUserId: Flow<Int?> = current.map { it?.config?.id }
|
||||
|
||||
/**
|
||||
* Whether Seerr integration is currently active of not
|
||||
*/
|
||||
val active: Flow<Boolean> =
|
||||
connection.map { it is SeerrConnectionStatus.Success && seerrApi.active }
|
||||
connection.map { it.connection is SeerrConnectionStatus.Success && seerrApi.active }
|
||||
|
||||
fun clear() {
|
||||
_connection.update { SeerrConnectionStatus.NotConfigured }
|
||||
_connection.update { SeerrState() }
|
||||
seerrApi.update("", null)
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +70,7 @@ class SeerrServerRepository
|
|||
user: SeerrUser,
|
||||
exception: Exception,
|
||||
) {
|
||||
_connection.update { SeerrConnectionStatus.Error(server, user, exception) }
|
||||
_connection.update { it.copy(connection = SeerrConnectionStatus.Error(server, user, exception)) }
|
||||
seerrApi.update("", null)
|
||||
}
|
||||
|
||||
|
|
@ -78,8 +81,9 @@ class SeerrServerRepository
|
|||
) {
|
||||
val publicSettings = seerrApi.api.settingsApi.settingsPublicGet()
|
||||
_connection.update {
|
||||
SeerrConnectionStatus.Success(
|
||||
CurrentSeerr(server, user, userConfig, publicSettings),
|
||||
it.copy(
|
||||
connection =
|
||||
SeerrConnectionStatus.Success(CurrentSeerr(server, user, userConfig, publicSettings)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -169,7 +173,7 @@ class SeerrServerRepository
|
|||
|
||||
suspend fun removeServerForCurrentUser(): Boolean {
|
||||
val user =
|
||||
when (val conn = connection.first()) {
|
||||
when (val conn = connection.value.connection) {
|
||||
SeerrConnectionStatus.NotConfigured -> return false
|
||||
is SeerrConnectionStatus.Error -> conn.user
|
||||
is SeerrConnectionStatus.Success -> conn.current.user
|
||||
|
|
@ -178,6 +182,81 @@ class SeerrServerRepository
|
|||
clear()
|
||||
return rows > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the [SeerrPluginSettings], try to connect to the Seerr server
|
||||
*
|
||||
* @return true if successfully updated the SeerrApi and logged in
|
||||
*/
|
||||
suspend fun usePluginProvidedServer(settings: SeerrPluginSettings): Boolean {
|
||||
val currentUser = serverRepository.currentUser ?: return false
|
||||
if (settings.serverUrl.isNotNullOrBlank() && settings.login != null && settings.login != SeerrPluginLogin.None) {
|
||||
val server =
|
||||
SeerrServer(
|
||||
id = -1,
|
||||
url = settings.serverUrl,
|
||||
)
|
||||
val user =
|
||||
SeerrUser(
|
||||
jellyfinUserRowId = currentUser.rowId,
|
||||
serverId = server.id,
|
||||
authMethod =
|
||||
when (settings.login) {
|
||||
is SeerrPluginLogin.ApiKey -> SeerrAuthMethod.API_KEY
|
||||
is SeerrPluginLogin.Local -> SeerrAuthMethod.LOCAL
|
||||
},
|
||||
username =
|
||||
when (settings.login) {
|
||||
is SeerrPluginLogin.ApiKey -> null
|
||||
is SeerrPluginLogin.Local -> settings.login.local.username
|
||||
},
|
||||
password = null,
|
||||
credential = null,
|
||||
)
|
||||
try {
|
||||
val userConfig =
|
||||
when (settings.login) {
|
||||
is SeerrPluginLogin.ApiKey -> {
|
||||
Timber.v("maybeAddPluginServer, using API key")
|
||||
seerrApi.update(settings.serverUrl, settings.login.apiKey)
|
||||
seerrLogin(seerrApi.api, SeerrAuthMethod.API_KEY, null, null)
|
||||
}
|
||||
|
||||
is SeerrPluginLogin.Local -> {
|
||||
Timber.v("maybeAddPluginServer, using local user")
|
||||
seerrApi.update(settings.serverUrl, null)
|
||||
seerrLogin(
|
||||
seerrApi.api,
|
||||
SeerrAuthMethod.LOCAL,
|
||||
settings.login.local.username,
|
||||
settings.login.local.password,
|
||||
)
|
||||
}
|
||||
|
||||
SeerrPluginLogin.None -> {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Success
|
||||
_connection.update { it.copy(pluginProvided = true) }
|
||||
set(server, user, userConfig)
|
||||
return true
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
clear()
|
||||
Timber.w(ex, "Error adding plugin provided seerr")
|
||||
_connection.update {
|
||||
it.copy(
|
||||
pluginProvided = true,
|
||||
connection = SeerrConnectionStatus.Error(server, user, ex),
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -185,6 +264,11 @@ class SeerrServerRepository
|
|||
*/
|
||||
typealias SeerrUserConfig = User
|
||||
|
||||
data class SeerrState(
|
||||
val connection: SeerrConnectionStatus = SeerrConnectionStatus.NotConfigured,
|
||||
val pluginProvided: Boolean = false,
|
||||
)
|
||||
|
||||
sealed interface SeerrConnectionStatus {
|
||||
data object NotConfigured : SeerrConnectionStatus
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
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 com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.IOException
|
||||
|
|
@ -32,6 +34,7 @@ class ServerPluginApi
|
|||
|
||||
companion object {
|
||||
private const val HOME_CONFIG_PATH = "homesettings"
|
||||
private const val SEERR_CONFIG_PATH = "seerrsettings"
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -57,32 +60,37 @@ class ServerPluginApi
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default home page settings from the plugin
|
||||
*
|
||||
* @return the home page settings or null if no rows are configured or the server isn't installed
|
||||
*/
|
||||
private suspend inline fun <reified T> executeRequest(path: String): T? {
|
||||
val url = createUrl(path) ?: return null
|
||||
val request = Request(url.toHttpUrl())
|
||||
return executeRequest<T>(request)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
suspend fun fetchHomePageSettings(): HomePageSettings? =
|
||||
private suspend inline fun <reified T> executeRequest(request: Request): T? =
|
||||
withContext(WholphinDispatchers.IO) {
|
||||
val url = createUrl(HOME_CONFIG_PATH) ?: return@withContext null
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
return@withContext okHttpClient.newCall(request).execute().use { res ->
|
||||
okHttpClient.newCall(request).execute().use { res ->
|
||||
if (res.isSuccessful) {
|
||||
json
|
||||
.decodeFromStream<HomePageSettings>(res.body.byteStream())
|
||||
.takeIf { it.rows.isNotEmpty() }
|
||||
json.decodeFromStream<T>(res.body.byteStream())
|
||||
} else if (res.code == 404) {
|
||||
Timber.w("fetchHomePageSettings returned 404")
|
||||
Timber.w("Plugin 404: %s", request.url.encodedPath)
|
||||
null
|
||||
} else {
|
||||
throw ApiClientException(res.code.toString() + " " + res.body.string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default home page settings from the plugin
|
||||
*
|
||||
* @return the home page settings or null if no rows are configured or the server isn't installed
|
||||
*/
|
||||
suspend fun fetchHomePageSettings(): HomePageSettings? =
|
||||
executeRequest<HomePageSettings>(HOME_CONFIG_PATH)?.takeIf { it.rows.isNotEmpty() }
|
||||
|
||||
/**
|
||||
* Get the [SeerrPluginSettings] as configured by teh server plugin, or null if none are configured
|
||||
*/
|
||||
suspend fun fetchSeerrSettings(): SeerrPluginSettings? = executeRequest(SEERR_CONFIG_PATH)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ class UserSwitchListener
|
|||
AppCompatDelegate.setApplicationLocales(localeList)
|
||||
}
|
||||
|
||||
// Check if plugin is installed, then for home settings
|
||||
// Check if plugin is installed, then for home settings & seerr settings
|
||||
launchIO {
|
||||
val serverPluginInstalled =
|
||||
try {
|
||||
|
|
@ -81,48 +81,64 @@ class UserSwitchListener
|
|||
Timber.i("Server plugin installed: %s", serverPluginInstalled)
|
||||
serverRepository.serverPluginInstalled.value = serverPluginInstalled
|
||||
|
||||
// Check for home settings
|
||||
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)
|
||||
// Check for home settings
|
||||
homeSettingsService.loadCurrentSettings(user)
|
||||
}
|
||||
|
||||
if (BuildConfig.DISCOVER_ENABLED) {
|
||||
// Check for seerr server
|
||||
launchIO {
|
||||
if (serverPluginInstalled) {
|
||||
serverPluginApi.fetchSeerrSettings()?.let {
|
||||
Timber.d("Trying seerr auto login")
|
||||
val result = seerrServerRepository.usePluginProvidedServer(it)
|
||||
if (result) {
|
||||
// Successfully used server plugin provided seerr
|
||||
Timber.i("Seerr auto login succeeded")
|
||||
return@launchIO
|
||||
}
|
||||
}
|
||||
}
|
||||
Timber.v("Checking for existing configured seerr server")
|
||||
// Either no server plugin seerr or there was an issue with it
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -450,12 +450,14 @@ fun ConfirmDialog(
|
|||
properties: DialogProperties = DialogProperties(),
|
||||
elevation: Dp = 1.dp,
|
||||
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
@StringRes cancelStringRes: Int = R.string.cancel,
|
||||
@StringRes confirmStringRes: Int = R.string.confirm,
|
||||
) = BasicDialog(
|
||||
onDismissRequest = onCancel,
|
||||
properties = properties,
|
||||
elevation = elevation,
|
||||
content = {
|
||||
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor)
|
||||
ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor, cancelStringRes, confirmStringRes)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -470,6 +472,8 @@ fun ConfirmDialogContent(
|
|||
onConfirm: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
bodyColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
@StringRes cancelStringRes: Int = R.string.cancel,
|
||||
@StringRes confirmStringRes: Int = R.string.confirm,
|
||||
) {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
|
@ -500,11 +504,11 @@ fun ConfirmDialogContent(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
TextButton(
|
||||
stringRes = R.string.cancel,
|
||||
stringRes = cancelStringRes,
|
||||
onClick = onCancel,
|
||||
)
|
||||
TextButton(
|
||||
stringRes = R.string.confirm,
|
||||
stringRes = confirmStringRes,
|
||||
onClick = onConfirm,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ enum class PreferenceScreenOption {
|
|||
fun rememberSeerrDialogBody(
|
||||
user: SeerrUser,
|
||||
server: SeerrServer,
|
||||
pluginProvided: Boolean,
|
||||
ex: Exception? = null,
|
||||
): String {
|
||||
val username =
|
||||
|
|
@ -81,8 +82,13 @@ fun rememberSeerrDialogBody(
|
|||
${stringResource(R.string.url)}: ${server.url}
|
||||
${stringResource(R.string.username)}: $username$method
|
||||
""".trimIndent() +
|
||||
if (pluginProvided) {
|
||||
"\n\n${stringResource(R.string.managed_by_server)}"
|
||||
} else {
|
||||
""
|
||||
} +
|
||||
if (ex != null) {
|
||||
"\n\n${stringResource(R.string.voice_error_server)}: ${ex.localizedMessage}"
|
||||
"\n\n${stringResource(R.string.error)}: ${ex.localizedMessage}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,8 +48,6 @@ import androidx.tv.material3.surfaceColorAtElevation
|
|||
import coil3.SingletonImageLoader
|
||||
import coil3.imageLoader
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.AppSwitchPreference
|
||||
|
|
@ -66,7 +64,6 @@ import com.github.damontecres.wholphin.preferences.experimentalPreferences
|
|||
import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling
|
||||
import com.github.damontecres.wholphin.preferences.screensaverPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
|
||||
import com.github.damontecres.wholphin.services.CurrentSeerr
|
||||
import com.github.damontecres.wholphin.services.Release
|
||||
import com.github.damontecres.wholphin.services.SeerrConnectionStatus
|
||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||
|
|
@ -83,7 +80,8 @@ import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
|||
import com.github.damontecres.wholphin.ui.serverSupportedMessage
|
||||
import com.github.damontecres.wholphin.ui.setup.ReleaseNotes
|
||||
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialog
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialogMode
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
|
|
@ -501,7 +499,7 @@ fun PreferencesContent(
|
|||
title = stringResource(pref.title),
|
||||
onClick = {
|
||||
seerrDialogMode =
|
||||
when (val conn = seerrConnection) {
|
||||
when (val conn = seerrConnection.connection) {
|
||||
is SeerrConnectionStatus.Error -> {
|
||||
SeerrDialogMode.Error(
|
||||
conn.server,
|
||||
|
|
@ -523,15 +521,13 @@ fun PreferencesContent(
|
|||
},
|
||||
modifier = focusModifier,
|
||||
summary =
|
||||
when (seerrConnection) {
|
||||
when (seerrConnection.connection) {
|
||||
is SeerrConnectionStatus.Error -> {
|
||||
stringResource(R.string.voice_error_server)
|
||||
}
|
||||
|
||||
SeerrConnectionStatus.NotConfigured -> {
|
||||
stringResource(
|
||||
R.string.add_server,
|
||||
)
|
||||
stringResource(R.string.add_server)
|
||||
}
|
||||
|
||||
is SeerrConnectionStatus.Success -> {
|
||||
|
|
@ -726,59 +722,15 @@ fun PreferencesContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
when (val mode = seerrDialogMode) {
|
||||
is SeerrDialogMode.Remove -> {
|
||||
val body = rememberSeerrDialogBody(mode.current.user, mode.current.server)
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.remove_seerr_server),
|
||||
body = body,
|
||||
onCancel = { seerrDialogMode = SeerrDialogMode.None },
|
||||
onConfirm = {
|
||||
seerrVm.removeServer()
|
||||
seerrDialogMode = SeerrDialogMode.None
|
||||
},
|
||||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
|
||||
SeerrDialogMode.Add -> {
|
||||
val currentUser by seerrVm.currentUser.collectAsState(null)
|
||||
val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending)
|
||||
val serverAddedMessage = stringResource(R.string.seerr_server_added)
|
||||
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 },
|
||||
)
|
||||
}
|
||||
|
||||
is SeerrDialogMode.Error -> {
|
||||
val body = rememberSeerrDialogBody(mode.user, mode.server, mode.ex)
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.remove_seerr_server),
|
||||
body = body,
|
||||
onCancel = { seerrDialogMode = SeerrDialogMode.None },
|
||||
onConfirm = {
|
||||
seerrVm.removeServer()
|
||||
seerrDialogMode = SeerrDialogMode.None
|
||||
},
|
||||
bodyColor = MaterialTheme.colorScheme.error,
|
||||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
|
||||
SeerrDialogMode.None -> {}
|
||||
}
|
||||
}
|
||||
|
||||
SeerrDialog(
|
||||
seerrDialogMode = seerrDialogMode,
|
||||
seerrConnection = seerrConnection,
|
||||
onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
|
||||
seerrVm = seerrVm,
|
||||
)
|
||||
|
||||
if (showQuickConnectDialog) {
|
||||
val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending)
|
||||
val successMessage = stringResource(R.string.quick_connect_success)
|
||||
|
|
@ -880,19 +832,3 @@ data class CacheUsage(
|
|||
val imageMemoryMax: Long,
|
||||
val imageDiskUsed: Long,
|
||||
)
|
||||
|
||||
private sealed interface SeerrDialogMode {
|
||||
data object None : SeerrDialogMode
|
||||
|
||||
data object Add : SeerrDialogMode
|
||||
|
||||
data class Remove(
|
||||
val current: CurrentSeerr,
|
||||
) : SeerrDialogMode
|
||||
|
||||
data class Error(
|
||||
val server: SeerrServer,
|
||||
val user: SeerrUser,
|
||||
val ex: Exception,
|
||||
) : SeerrDialogMode
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.util.LoadingState
|
|||
|
||||
@Composable
|
||||
fun AddSeerrServerApiKey(
|
||||
initialUrl: String,
|
||||
onSubmit: (url: String, apiKey: String) -> Unit,
|
||||
status: LoadingState,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -54,12 +55,18 @@ fun AddSeerrServerApiKey(
|
|||
.padding(16.dp)
|
||||
.wrapContentSize(),
|
||||
) {
|
||||
var url by remember { mutableStateOf("") }
|
||||
var url by remember { mutableStateOf(initialUrl) }
|
||||
var apiKey by remember { mutableStateOf("") }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val passwordFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
LaunchedEffect(Unit) {
|
||||
if (initialUrl.isBlank()) {
|
||||
focusRequester.tryRequestFocus()
|
||||
} else {
|
||||
passwordFocusRequester.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.enter_url_api_key),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
|
|
@ -152,6 +159,7 @@ fun AddSeerrServerApiKey(
|
|||
|
||||
@Composable
|
||||
fun AddSeerrServerUsername(
|
||||
initialUrl: String,
|
||||
onSubmit: (url: String, username: String, password: String) -> Unit,
|
||||
username: String,
|
||||
status: LoadingState,
|
||||
|
|
@ -166,14 +174,22 @@ fun AddSeerrServerUsername(
|
|||
.padding(16.dp)
|
||||
.wrapContentSize(),
|
||||
) {
|
||||
var url by remember { mutableStateOf("") }
|
||||
var url by remember { mutableStateOf(initialUrl) }
|
||||
var username by remember { mutableStateOf(username) }
|
||||
var password by remember { mutableStateOf("") }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val usernameFocusRequester = remember { FocusRequester() }
|
||||
val passwordFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
LaunchedEffect(Unit) {
|
||||
val focusRequesterToUse =
|
||||
when {
|
||||
url.isNotNullOrBlank() && username.isNotNullOrBlank() -> passwordFocusRequester
|
||||
url.isNotNullOrBlank() -> usernameFocusRequester
|
||||
else -> focusRequester
|
||||
}
|
||||
focusRequesterToUse.tryRequestFocus()
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.username_or_password),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
|
|
@ -317,6 +333,7 @@ fun AddSeerrServerUsername(
|
|||
private fun AddSeerrServerUsernamePreview() {
|
||||
WholphinTheme {
|
||||
AddSeerrServerUsername(
|
||||
initialUrl = "",
|
||||
onSubmit = { string: String, string1: String, string2: String -> },
|
||||
username = "test",
|
||||
status = LoadingState.Pending,
|
||||
|
|
|
|||
|
|
@ -1,27 +1,161 @@
|
|||
package com.github.damontecres.wholphin.ui.setup.seerr
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
||||
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||
import com.github.damontecres.wholphin.services.CurrentSeerr
|
||||
import com.github.damontecres.wholphin.services.SeerrState
|
||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.ScrollableDialog
|
||||
import com.github.damontecres.wholphin.ui.preferences.rememberSeerrDialogBody
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
|
||||
@Composable
|
||||
fun SeerrDialog(
|
||||
seerrDialogMode: SeerrDialogMode,
|
||||
seerrConnection: SeerrState,
|
||||
onDismissRequest: () -> Unit,
|
||||
seerrVm: SwitchSeerrViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
when (val mode = seerrDialogMode) {
|
||||
is SeerrDialogMode.Remove -> {
|
||||
val body = rememberSeerrDialogBody(mode.current.user, mode.current.server, seerrConnection.pluginProvided)
|
||||
if (seerrConnection.pluginProvided) {
|
||||
ScrollableDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
) {
|
||||
item {
|
||||
Text(body)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.remove_seerr_server),
|
||||
body = body,
|
||||
onCancel = onDismissRequest,
|
||||
onConfirm = {
|
||||
seerrVm.removeServer()
|
||||
onDismissRequest.invoke()
|
||||
},
|
||||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SeerrDialogMode.Add -> {
|
||||
val currentUser by seerrVm.currentUser.collectAsState(null)
|
||||
val state by seerrVm.state.collectAsState()
|
||||
val serverAddedMessage = stringResource(R.string.seerr_server_added)
|
||||
LaunchedEffect(state.serverConnectionStatus) {
|
||||
if (state.serverConnectionStatus == LoadingState.Success) {
|
||||
Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show()
|
||||
onDismissRequest.invoke()
|
||||
}
|
||||
}
|
||||
when (state.loading) {
|
||||
is LoadingState.Error,
|
||||
LoadingState.Loading,
|
||||
LoadingState.Pending,
|
||||
-> {
|
||||
BasicDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
) {
|
||||
LoadingPage(Modifier.padding(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
LoadingState.Success -> {
|
||||
AddSeerServerDialog(
|
||||
initialUrl = state.initialUrl,
|
||||
currentUsername = currentUser?.name,
|
||||
status = state.serverConnectionStatus,
|
||||
onSubmit = seerrVm::submitServer,
|
||||
onResetStatus = seerrVm::resetStatus,
|
||||
onDismissRequest = onDismissRequest,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is SeerrDialogMode.Error -> {
|
||||
val body = rememberSeerrDialogBody(mode.user, mode.server, seerrConnection.pluginProvided, mode.ex)
|
||||
if (seerrConnection.pluginProvided) {
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.error),
|
||||
body = body,
|
||||
onCancel = onDismissRequest,
|
||||
onConfirm = {
|
||||
seerrVm.retryServerFromPlugin()
|
||||
onDismissRequest.invoke()
|
||||
},
|
||||
bodyColor = MaterialTheme.colorScheme.error,
|
||||
elevation = 3.dp,
|
||||
confirmStringRes = R.string.retry,
|
||||
)
|
||||
} else {
|
||||
ConfirmDialog(
|
||||
title = stringResource(R.string.remove_seerr_server),
|
||||
body = body,
|
||||
onCancel = onDismissRequest,
|
||||
onConfirm = {
|
||||
seerrVm.removeServer()
|
||||
onDismissRequest.invoke()
|
||||
},
|
||||
bodyColor = MaterialTheme.colorScheme.error,
|
||||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SeerrDialogMode.None -> {}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface SeerrDialogMode {
|
||||
data object None : SeerrDialogMode
|
||||
|
||||
data object Add : SeerrDialogMode
|
||||
|
||||
data class Remove(
|
||||
val current: CurrentSeerr,
|
||||
) : SeerrDialogMode
|
||||
|
||||
data class Error(
|
||||
val server: SeerrServer,
|
||||
val user: SeerrUser,
|
||||
val ex: Exception,
|
||||
) : SeerrDialogMode
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddSeerServerDialog(
|
||||
currentUsername: String?,
|
||||
initialUrl: String?,
|
||||
status: LoadingState,
|
||||
onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit,
|
||||
onResetStatus: () -> Unit,
|
||||
|
|
@ -42,6 +176,7 @@ fun AddSeerServerDialog(
|
|||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
AddSeerrServerUsername(
|
||||
initialUrl = initialUrl ?: "",
|
||||
onSubmit = { url, username, password ->
|
||||
onSubmit.invoke(url, username, password, auth)
|
||||
},
|
||||
|
|
@ -57,6 +192,7 @@ fun AddSeerServerDialog(
|
|||
onDismissRequest = { authMethod = null },
|
||||
) {
|
||||
AddSeerrServerApiKey(
|
||||
initialUrl = initialUrl ?: "",
|
||||
onSubmit = { url, apiKey ->
|
||||
onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,11 +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.R
|
||||
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.services.ServerPluginApi
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
|
|
@ -15,6 +17,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import jakarta.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
|
|
@ -28,11 +31,29 @@ class SwitchSeerrViewModel
|
|||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val seerrService: SeerrService,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val serverPluginApi: ServerPluginApi,
|
||||
) : ViewModel() {
|
||||
val currentUser = serverRepository.currentUserFlow
|
||||
val currentSeerrServer = seerrServerRepository.currentServer
|
||||
|
||||
val serverConnectionStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
|
||||
private val _state = MutableStateFlow<SwitchSeerrState>(SwitchSeerrState())
|
||||
val state: StateFlow<SwitchSeerrState> = _state
|
||||
|
||||
init {
|
||||
if (serverRepository.serverPluginInstalled.value) {
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
serverPluginApi.fetchSeerrSettings()?.serverUrl?.let { serverUrl ->
|
||||
_state.update { it.copy(initialUrl = serverUrl) }
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching seerr settings")
|
||||
} finally {
|
||||
_state.update { it.copy(loading = LoadingState.Success) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun submitServer(
|
||||
url: String,
|
||||
|
|
@ -41,13 +62,21 @@ class SwitchSeerrViewModel
|
|||
authMethod: SeerrAuthMethod,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
serverConnectionStatus.update { LoadingState.Loading }
|
||||
_state.update { it.copy(serverConnectionStatus = LoadingState.Loading) }
|
||||
val urls =
|
||||
try {
|
||||
createUrls(url)
|
||||
} catch (ex: IllegalArgumentException) {
|
||||
showToast(context, "Invalid URL")
|
||||
serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
serverConnectionStatus =
|
||||
LoadingState.Error(
|
||||
"Invalid URL",
|
||||
ex,
|
||||
),
|
||||
)
|
||||
}
|
||||
return@launchIO
|
||||
}
|
||||
Timber.v("Urls to try: %s", urls)
|
||||
|
|
@ -98,7 +127,7 @@ class SwitchSeerrViewModel
|
|||
)
|
||||
}
|
||||
}
|
||||
serverConnectionStatus.update { LoadingState.Success }
|
||||
_state.update { it.copy(serverConnectionStatus = LoadingState.Success) }
|
||||
} else {
|
||||
val message =
|
||||
results
|
||||
|
|
@ -107,7 +136,7 @@ class SwitchSeerrViewModel
|
|||
"$url - ${s?.localizedMessage}"
|
||||
}.joinToString("\n")
|
||||
showToast(context, "Could not connect")
|
||||
serverConnectionStatus.update { LoadingState.Error(message) }
|
||||
_state.update { it.copy(serverConnectionStatus = LoadingState.Error(message)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -121,11 +150,31 @@ class SwitchSeerrViewModel
|
|||
}
|
||||
}
|
||||
|
||||
fun retryServerFromPlugin() {
|
||||
viewModelScope.launchIO {
|
||||
val settings = serverPluginApi.fetchSeerrSettings()
|
||||
if (settings != null) {
|
||||
val result = seerrServerRepository.usePluginProvidedServer(settings)
|
||||
if (!result) {
|
||||
showToast(context, context.getString(R.string.voice_error_server))
|
||||
}
|
||||
} else {
|
||||
seerrServerRepository.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetStatus() {
|
||||
serverConnectionStatus.update { LoadingState.Pending }
|
||||
_state.update { it.copy(serverConnectionStatus = LoadingState.Pending) }
|
||||
}
|
||||
}
|
||||
|
||||
data class SwitchSeerrState(
|
||||
val loading: LoadingState = LoadingState.Pending,
|
||||
val serverConnectionStatus: LoadingState = LoadingState.Pending,
|
||||
val initialUrl: String? = null,
|
||||
)
|
||||
|
||||
fun createUrls(url: String): List<HttpUrl> {
|
||||
val urls = mutableListOf<HttpUrl>()
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
|
|
|
|||
|
|
@ -977,4 +977,6 @@
|
|||
<string name="home_settings_source_server_profile">Server user profile</string>
|
||||
<string name="home_settings_source_plugin">Server default</string>
|
||||
<string name="home_settings_source_web">User web client</string>
|
||||
<string name="managed_by_server">Managed by server</string>
|
||||
<string name="error">Error</string>
|
||||
</resources>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue