mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 16:11:20 +02:00
Add integration with Jellyseerr/Seerr (#558)
## Description This PR add the ability to integrate with a [Jellyseerr/Seerr server](https://github.com/seerr-team/seerr). ### Features - Login to the Seerr server using API Key, Jellyfin credentials, or local Seerr credentials - Separate Seerr logins per user profile - Search Seerr from the search page - Discover media from Seerr on the nav drawer `Discover` page - Also from movie, series, or person pages - Seamless integration with existing media, i.e. selecting media found from Seerr that already exists on the Jellyfin server, will go directly to the regular media page - Mostly consistent UI between Jellyfin media & Seerr media - Submit requests to Seerr - Cancel submitted requests ### Screenshots  ### Related issues Closes #54 ## Acknowledgements The `app/src/main/seerr/seerr-api.yml` file is copied & modified from https://github.com/seerr-team/seerr/blob/develop/seerr-api.yml. I hope to try to upstream some of the changes in the future.
This commit is contained in:
parent
f4e1b0e171
commit
4c7c465c67
68 changed files with 13369 additions and 137 deletions
|
|
@ -15,6 +15,7 @@ import coil3.request.SuccessResult
|
|||
import coil3.request.allowHardware
|
||||
import coil3.request.bitmapConfig
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.BackdropStyle
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
|
|
@ -27,7 +28,6 @@ import kotlinx.coroutines.flow.update
|
|||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -47,27 +47,38 @@ class BackdropService
|
|||
|
||||
suspend fun submit(item: BaseItem) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
|
||||
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
|
||||
_backdropFlow.update {
|
||||
it.copy(
|
||||
itemId = item.id,
|
||||
imageUrl = null,
|
||||
)
|
||||
}
|
||||
extractColors(item)
|
||||
}
|
||||
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
|
||||
submit(item.id.toString(), imageUrl)
|
||||
}
|
||||
|
||||
suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl)
|
||||
|
||||
suspend fun submit(
|
||||
itemId: String,
|
||||
imageUrl: String?,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) {
|
||||
_backdropFlow.update {
|
||||
it.copy(
|
||||
itemId = itemId,
|
||||
imageUrl = null,
|
||||
)
|
||||
}
|
||||
extractColors(itemId, imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearBackdrop() {
|
||||
_backdropFlow.update {
|
||||
BackdropResult.NONE
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun extractColors(item: BaseItem) {
|
||||
private suspend fun extractColors(
|
||||
itemId: String,
|
||||
imageUrl: String?,
|
||||
) {
|
||||
delay(500)
|
||||
val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
|
||||
val backdropStyle =
|
||||
preferences.data
|
||||
.firstOrNull()
|
||||
|
|
@ -83,9 +94,9 @@ class BackdropService
|
|||
ExtractedColors.DEFAULT
|
||||
}
|
||||
_backdropFlow.update {
|
||||
if (it.itemId == item.id) {
|
||||
if (it.itemId == itemId) {
|
||||
BackdropResult(
|
||||
itemId = item.id,
|
||||
itemId = itemId,
|
||||
imageUrl = imageUrl,
|
||||
primaryColor = primaryColor,
|
||||
secondaryColor = secondaryColor,
|
||||
|
|
@ -208,7 +219,7 @@ class BackdropService
|
|||
}
|
||||
|
||||
data class BackdropResult(
|
||||
val itemId: UUID?,
|
||||
val itemId: String?,
|
||||
val imageUrl: String?,
|
||||
val primaryColor: Color = Color.Unspecified,
|
||||
val secondaryColor: Color = Color.Unspecified,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* Wrapper for [SeerrApiClient]. In most cases, you should use [SeerrService] instead.
|
||||
*/
|
||||
class SeerrApi(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
var api: SeerrApiClient =
|
||||
SeerrApiClient(
|
||||
baseUrl = "",
|
||||
apiKey = null,
|
||||
okHttpClient = okHttpClient,
|
||||
)
|
||||
private set
|
||||
|
||||
val active: Boolean get() = api.baseUrl.isNotNullOrBlank()
|
||||
|
||||
fun update(
|
||||
baseUrl: String,
|
||||
apiKey: String?,
|
||||
) {
|
||||
api = SeerrApiClient(baseUrl, apiKey, okHttpClient)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||
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.User
|
||||
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.SeerrServer
|
||||
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import dagger.hilt.android.scopes.ActivityScoped
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Manages saves/loading Seerr servers from the local DB. Also will update the current [SeerrApi] as needed.
|
||||
*/
|
||||
@Singleton
|
||||
class SeerrServerRepository
|
||||
@Inject
|
||||
constructor(
|
||||
private val seerrApi: SeerrApi,
|
||||
private val seerrServerDao: SeerrServerDao,
|
||||
private val serverRepository: ServerRepository,
|
||||
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
private val _current = MutableStateFlow<CurrentSeerr?>(null)
|
||||
val current: StateFlow<CurrentSeerr?> = _current
|
||||
val currentServer: Flow<SeerrServer?> = current.map { it?.server }
|
||||
val currentUser: Flow<SeerrUser?> = current.map { it?.user }
|
||||
|
||||
/**
|
||||
* Whether Seerr integration is currently active of not
|
||||
*/
|
||||
val active: Flow<Boolean> = current.map { it != null && seerrApi.active }
|
||||
|
||||
fun clear() {
|
||||
_current.update { null }
|
||||
seerrApi.update("", null)
|
||||
}
|
||||
|
||||
suspend fun set(
|
||||
server: SeerrServer,
|
||||
user: SeerrUser,
|
||||
userConfig: SeerrUserConfig,
|
||||
) {
|
||||
_current.update {
|
||||
CurrentSeerr(server, user, userConfig)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addAndChangeServer(
|
||||
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.value?.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.value?.let { jellyfinUser ->
|
||||
// TODO Need to update server early so that cookies are saved
|
||||
seerrApi.update(server.url, null)
|
||||
val userConfig = login(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun testConnection(
|
||||
authMethod: SeerrAuthMethod,
|
||||
url: String,
|
||||
username: String?,
|
||||
passwordOrApiKey: String,
|
||||
): LoadingState {
|
||||
val api = SeerrApiClient(url, passwordOrApiKey, okHttpClient)
|
||||
try {
|
||||
login(api, authMethod, username, passwordOrApiKey)
|
||||
return LoadingState.Success
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error testing seerr connection")
|
||||
return LoadingState.Error(ex)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeServer() {
|
||||
val current = _current.value ?: return
|
||||
seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [SeerrUser] config
|
||||
*/
|
||||
typealias SeerrUserConfig = User
|
||||
|
||||
data class CurrentSeerr(
|
||||
val server: SeerrServer,
|
||||
val user: SeerrUser,
|
||||
val config: SeerrUserConfig,
|
||||
)
|
||||
|
||||
private suspend fun login(
|
||||
client: SeerrApiClient,
|
||||
authMethod: SeerrAuthMethod,
|
||||
username: String?,
|
||||
password: String?,
|
||||
): User =
|
||||
when (authMethod) {
|
||||
SeerrAuthMethod.LOCAL -> {
|
||||
client.authApi.authLocalPost(
|
||||
AuthLocalPostRequest(
|
||||
email = username ?: "",
|
||||
password = password ?: "",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
SeerrAuthMethod.JELLYFIN -> {
|
||||
client.authApi.authJellyfinPost(
|
||||
AuthJellyfinPostRequest(
|
||||
username = username ?: "",
|
||||
password = password ?: "",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
SeerrAuthMethod.API_KEY -> {
|
||||
client.usersApi.authMeGet()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens for JF user switching in the app to also switch the Seerr user/server
|
||||
*/
|
||||
@ActivityScoped
|
||||
class UserSwitchListener
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ActivityContext private val context: Context,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val seerrServerDao: SeerrServerDao,
|
||||
private val seerrApi: SeerrApi,
|
||||
) {
|
||||
init {
|
||||
context as AppCompatActivity
|
||||
context.lifecycleScope.launchIO {
|
||||
serverRepository.currentUser.asFlow().collect { user ->
|
||||
Timber.d("New user")
|
||||
seerrServerRepository.clear()
|
||||
if (user != null) {
|
||||
seerrServerDao
|
||||
.getUsersByJellyfinUser(user.rowId)
|
||||
.firstOrNull()
|
||||
?.let { seerrUser ->
|
||||
val server = seerrServerDao.getServer(seerrUser.serverId)?.server
|
||||
if (server != null) {
|
||||
Timber.i("Found a seerr user & server")
|
||||
seerrApi.update(server.url, seerrUser.credential)
|
||||
val userConfig =
|
||||
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
|
||||
try {
|
||||
login(
|
||||
seerrApi.api,
|
||||
seerrUser.authMethod,
|
||||
seerrUser.username,
|
||||
seerrUser.password,
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error logging into %s", server.url)
|
||||
seerrServerRepository.clear()
|
||||
return@let
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
seerrApi.api.usersApi.authMeGet()
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error logging into %s", server.url)
|
||||
seerrServerRepository.clear()
|
||||
return@let
|
||||
}
|
||||
}
|
||||
seerrServerRepository.set(server, seerrUser, userConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||
import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
typealias SeerrSearchResult = SearchGet200ResponseResultsInner
|
||||
|
||||
/**
|
||||
* Main access for the current Seerr server (if any)
|
||||
*
|
||||
* Exposes a [SeerrApiClient] for queries
|
||||
*/
|
||||
@Singleton
|
||||
class SeerrService
|
||||
@Inject
|
||||
constructor(
|
||||
private val seerApi: SeerrApi,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
) {
|
||||
val api: SeerrApiClient get() = seerApi.api
|
||||
|
||||
val active get() = seerrServerRepository.active
|
||||
|
||||
suspend fun search(
|
||||
query: String,
|
||||
page: Int = 1,
|
||||
): List<SeerrSearchResult> =
|
||||
api.searchApi
|
||||
.searchGet(query = query, page = page)
|
||||
.results
|
||||
.orEmpty()
|
||||
|
||||
suspend fun discoverTv(page: Int = 1): List<DiscoverItem> =
|
||||
api.searchApi
|
||||
.discoverTvGet(page = page)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
|
||||
suspend fun discoverMovies(page: Int = 1): List<DiscoverItem> =
|
||||
api.searchApi
|
||||
.discoverMoviesGet(page = page)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
|
||||
suspend fun trending(page: Int = 1): List<DiscoverItem> =
|
||||
api.searchApi
|
||||
.discoverTrendingGet(page = page)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
|
||||
suspend fun upcomingMovies(page: Int = 1): List<DiscoverItem> =
|
||||
api.searchApi
|
||||
.discoverMoviesUpcomingGet(page = page)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
|
||||
suspend fun upcomingTv(page: Int = 1): List<DiscoverItem> =
|
||||
api.searchApi
|
||||
.discoverTvUpcomingGet(page = page)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
|
||||
/**
|
||||
* Get [DiscoverItem]s similar to the JF items such as movies, series, or people
|
||||
*
|
||||
* If Seerr integration is not active, this short circuits to return null
|
||||
*
|
||||
* @return the discovered items or null if no Seerr server configured
|
||||
*/
|
||||
suspend fun similar(item: BaseItem): List<DiscoverItem>? =
|
||||
if (active.first()) {
|
||||
item.data.providerIds
|
||||
?.get("Tmdb")
|
||||
?.toIntOrNull()
|
||||
?.let {
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> {
|
||||
api.moviesApi
|
||||
.movieMovieIdSimilarGet(movieId = it)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
}
|
||||
|
||||
BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> {
|
||||
api.tvApi
|
||||
.tvTvIdSimilarGet(tvId = it)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
}
|
||||
|
||||
BaseItemKind.PERSON -> {
|
||||
api.personApi
|
||||
.personPersonIdCombinedCreditsGet(personId = it)
|
||||
.let { credits ->
|
||||
val cast =
|
||||
credits.cast
|
||||
?.take(25)
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
val crew =
|
||||
credits.crew
|
||||
?.take(25)
|
||||
?.map(::DiscoverItem)
|
||||
.orEmpty()
|
||||
cast + crew
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}.orEmpty()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -88,7 +88,6 @@ class TrailerService
|
|||
navigateTo.invoke(
|
||||
Destination.Playback(
|
||||
itemId = trailer.baseItem.id,
|
||||
item = trailer.baseItem,
|
||||
positionMs = 0L,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import kotlinx.serialization.json.jsonPrimitive
|
|||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.headersContentLength
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
|
@ -211,7 +210,7 @@ class UpdateChecker
|
|||
if (it.isSuccessful && it.body != null) {
|
||||
Timber.v("Request successful for ${release.downloadUrl}")
|
||||
withContext(Dispatchers.Main) {
|
||||
callback.contentLength(it.headersContentLength())
|
||||
callback.contentLength(it.body.contentLength())
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val contentValues =
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
|||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||
import com.github.damontecres.wholphin.services.SeerrApi
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||
import dagger.Module
|
||||
|
|
@ -174,4 +175,10 @@ object AppModule {
|
|||
@Singleton
|
||||
@IoCoroutineScope
|
||||
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun seerrApi(
|
||||
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||
) = SeerrApi(okHttpClient)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao
|
|||
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||
import com.github.damontecres.wholphin.data.Migrations
|
||||
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
||||
|
|
@ -61,6 +62,10 @@ object DatabaseModule {
|
|||
@Singleton
|
||||
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun userPreferencesDataStore(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue