mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 16:11:20 +02:00
Basic login UI flow
This commit is contained in:
parent
a2884a7225
commit
6046799b91
17 changed files with 577 additions and 69 deletions
|
|
@ -2,7 +2,7 @@
|
|||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 20,
|
||||
"identityHash": "4385c87cd6f510f285ec9802cff1ce2d",
|
||||
"identityHash": "dea51adcc724179afa0174d775f97480",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "servers",
|
||||
|
|
@ -427,7 +427,7 @@
|
|||
},
|
||||
{
|
||||
"tableName": "seerr_servers",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT)",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
|
|
@ -435,17 +435,17 @@
|
|||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "version",
|
||||
"columnName": "version",
|
||||
|
|
@ -472,11 +472,11 @@
|
|||
},
|
||||
{
|
||||
"tableName": "seerr_users",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "jellyfinRowId",
|
||||
"columnName": "jellyfinRowId",
|
||||
"fieldPath": "jellyfinUserRowId",
|
||||
"columnName": "jellyfinUserRowId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
|
|
@ -511,7 +511,7 @@
|
|||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"jellyfinRowId",
|
||||
"jellyfinUserRowId",
|
||||
"serverId"
|
||||
]
|
||||
},
|
||||
|
|
@ -532,7 +532,7 @@
|
|||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"jellyfinRowId"
|
||||
"jellyfinUserRowId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"rowId"
|
||||
|
|
@ -543,7 +543,7 @@
|
|||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4385c87cd6f510f285ec9802cff1ce2d')"
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dea51adcc724179afa0174d775f97480')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
|
|||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
|
||||
import com.github.damontecres.wholphin.services.DeviceProfileService
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
|
|
@ -44,6 +45,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService
|
|||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||
import com.github.damontecres.wholphin.services.UpdateChecker
|
||||
import com.github.damontecres.wholphin.services.UserSwitchListener
|
||||
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
|
|
@ -97,6 +99,12 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var refreshRateService: RefreshRateService
|
||||
|
||||
@Inject
|
||||
lateinit var datePlayedInvalidationService: DatePlayedInvalidationService
|
||||
|
||||
@Inject
|
||||
lateinit var userSwitchListener: UserSwitchListener
|
||||
|
||||
private var signInAuto = true
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
|
|
|
|||
|
|
@ -3,17 +3,24 @@ package com.github.damontecres.wholphin.api.seerr
|
|||
import com.github.damontecres.wholphin.api.seerr.infrastructure.ApiClient
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import okhttp3.Call
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
|
||||
class SeerrApiClient(
|
||||
val baseUrl: String,
|
||||
val apiKey: String?,
|
||||
private val apiKey: String?,
|
||||
okHttpClient: OkHttpClient,
|
||||
) {
|
||||
private val cookieJar = SeerrCookieJar()
|
||||
|
||||
private val client =
|
||||
okHttpClient
|
||||
.newBuilder()
|
||||
.cookieJar(cookieJar)
|
||||
.addInterceptor {
|
||||
Timber.d("SeerrApiClient: ${it.request().method} ${it.request().url}")
|
||||
it.proceed(
|
||||
|
|
@ -26,6 +33,11 @@ class SeerrApiClient(
|
|||
)
|
||||
}.build()
|
||||
|
||||
val hasValidCredentials: Boolean
|
||||
get() =
|
||||
apiKey.isNotNullOrBlank() ||
|
||||
cookieJar.hasValidCredentials(baseUrl)
|
||||
|
||||
private fun <T : ApiClient> create(initializer: (String, Call.Factory) -> T): Lazy<T> =
|
||||
lazy {
|
||||
initializer.invoke(baseUrl, client)
|
||||
|
|
@ -50,3 +62,26 @@ class SeerrApiClient(
|
|||
val usersApi by create(::UsersApi)
|
||||
val watchlistApi by create(::WatchlistApi)
|
||||
}
|
||||
|
||||
private class SeerrCookieJar : CookieJar {
|
||||
private val cookies = mutableMapOf<String, List<Cookie>>()
|
||||
|
||||
override fun saveFromResponse(
|
||||
url: HttpUrl,
|
||||
cookies: List<Cookie>,
|
||||
) {
|
||||
cookies
|
||||
.filter { it.name == "connect.sid" }
|
||||
.groupBy { it.domain }
|
||||
.forEach { (domain, cookies) ->
|
||||
this.cookies[domain] = cookies
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> = this.cookies[url.host].orEmpty()
|
||||
|
||||
fun hasValidCredentials(baseUrl: String): Boolean =
|
||||
baseUrl.toHttpUrlOrNull()?.host?.let { domain ->
|
||||
cookies[domain]?.any { it.expiresAt > System.currentTimeMillis() }
|
||||
} == true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import androidx.room.Update
|
|||
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||
import com.github.damontecres.wholphin.data.model.SeerrServerUsers
|
||||
import com.github.damontecres.wholphin.data.model.SeerrUser
|
||||
import java.util.UUID
|
||||
|
||||
@Dao
|
||||
interface SeerrServerDao {
|
||||
|
|
@ -32,22 +31,25 @@ interface SeerrServerDao {
|
|||
|
||||
suspend fun updateUser(user: SeerrUser) = addUser(user)
|
||||
|
||||
@Query("SELECT * FROM seerr_users WHERE serverId = :serverId AND jellyfinRowId = :userId")
|
||||
@Query("SELECT * FROM seerr_users WHERE serverId = :serverId AND jellyfinUserRowId = :jellyfinUserRowId")
|
||||
suspend fun getUser(
|
||||
serverId: Int,
|
||||
userId: UUID,
|
||||
jellyfinUserRowId: Int,
|
||||
): SeerrUser?
|
||||
|
||||
@Query("SELECT * FROM seerr_users WHERE jellyfinUserRowId = :jellyfinUserRowId")
|
||||
suspend fun getUsersByJellyfinUser(jellyfinUserRowId: Int): List<SeerrUser>
|
||||
|
||||
@Query("DELETE FROM seerr_servers WHERE id = :serverId")
|
||||
suspend fun deleteServer(serverId: Int)
|
||||
|
||||
@Query("DELETE FROM seerr_users WHERE serverId = :serverId AND jellyfinRowId = :jellyfinRowId")
|
||||
@Query("DELETE FROM seerr_users WHERE serverId = :serverId AND jellyfinUserRowId = :jellyfinUserRowId")
|
||||
suspend fun deleteUser(
|
||||
serverId: Int,
|
||||
jellyfinRowId: Int,
|
||||
jellyfinUserRowId: Int,
|
||||
)
|
||||
|
||||
suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinRowId)
|
||||
suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId)
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM seerr_servers")
|
||||
|
|
|
|||
|
|
@ -38,14 +38,14 @@ data class SeerrServer(
|
|||
ForeignKey(
|
||||
entity = JellyfinUser::class,
|
||||
parentColumns = arrayOf("rowId"),
|
||||
childColumns = arrayOf("jellyfinRowId"),
|
||||
childColumns = arrayOf("jellyfinUserRowId"),
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
primaryKeys = ["jellyfinRowId", "serverId"],
|
||||
primaryKeys = ["jellyfinUserRowId", "serverId"],
|
||||
)
|
||||
data class SeerrUser(
|
||||
val jellyfinRowId: Int,
|
||||
val jellyfinUserRowId: Int,
|
||||
val serverId: Int,
|
||||
val authMethod: SeerrAuthMethod,
|
||||
val username: String?,
|
||||
|
|
@ -53,7 +53,7 @@ data class SeerrUser(
|
|||
val credential: String?,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"SeerrUser(jellyfinRowId=$jellyfinRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})"
|
||||
"SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})"
|
||||
}
|
||||
|
||||
enum class SeerrAuthMethod {
|
||||
|
|
|
|||
|
|
@ -842,6 +842,13 @@ sealed interface AppPreference<Pref, T> {
|
|||
summaryOn = R.string.enabled,
|
||||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
val SeerrIntegration =
|
||||
AppClickablePreference<AppPreferences>(
|
||||
title = R.string.seerr_integration,
|
||||
getter = { },
|
||||
setter = { prefs, _ -> prefs },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -903,6 +910,7 @@ val basicPreferences =
|
|||
title = R.string.more,
|
||||
preferences =
|
||||
listOf(
|
||||
AppPreference.SeerrIntegration,
|
||||
AppPreference.AdvancedSettings,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
|
|
@ -11,18 +10,20 @@ class SeerrApi(
|
|||
@param:ApplicationContext private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("seerr", Context.MODE_PRIVATE)
|
||||
// private val prefs: SharedPreferences =
|
||||
// context.getSharedPreferences("seerr", Context.MODE_PRIVATE)
|
||||
|
||||
var api: SeerrApiClient =
|
||||
SeerrApiClient(
|
||||
prefs.getString("baseUrl", null)!! + "/api/v1",
|
||||
prefs.getString("apiKey", null)!!,
|
||||
okHttpClient,
|
||||
// prefs.getString("baseUrl", null)!! + "/api/v1",
|
||||
// prefs.getString("apiKey", null)!!,
|
||||
baseUrl = "",
|
||||
apiKey = null,
|
||||
okHttpClient = okHttpClient,
|
||||
)
|
||||
private set
|
||||
|
||||
val active: Boolean get() = api.baseUrl.isNotNullOrBlank() && api.apiKey.isNotNullOrBlank()
|
||||
val active: Boolean get() = api.baseUrl.isNotNullOrBlank()
|
||||
|
||||
fun update(
|
||||
baseUrl: String,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,29 @@
|
|||
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.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -21,6 +34,7 @@ class SeerrServerRepository
|
|||
private val seerrApi: SeerrApi,
|
||||
private val seerrServerDao: SeerrServerDao,
|
||||
private val serverRepository: ServerRepository,
|
||||
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
private val _currentServer = MutableStateFlow<SeerrServer?>(null)
|
||||
val currentServer: StateFlow<SeerrServer?> = _currentServer
|
||||
|
|
@ -37,6 +51,24 @@ class SeerrServerRepository
|
|||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_currentServer.update { null }
|
||||
_currentUser.update { null }
|
||||
seerrApi.update("", null)
|
||||
}
|
||||
|
||||
suspend fun set(
|
||||
server: SeerrServer,
|
||||
user: SeerrUser,
|
||||
) {
|
||||
_currentServer.update {
|
||||
_currentUser.update {
|
||||
user
|
||||
}
|
||||
server
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addAndChangeServer(
|
||||
url: String,
|
||||
apiKey: String,
|
||||
|
|
@ -51,7 +83,7 @@ class SeerrServerRepository
|
|||
// TODO test api key
|
||||
val user =
|
||||
SeerrUser(
|
||||
jellyfinRowId = jellyfinUser.rowId,
|
||||
jellyfinUserRowId = jellyfinUser.rowId,
|
||||
serverId = server.id,
|
||||
authMethod = SeerrAuthMethod.API_KEY,
|
||||
username = null,
|
||||
|
|
@ -59,8 +91,6 @@ class SeerrServerRepository
|
|||
credential = apiKey,
|
||||
)
|
||||
seerrServerDao.addUser(user)
|
||||
// TODO user info
|
||||
seerrApi.api.usersApi.authMeGet()
|
||||
|
||||
seerrApi.update(server.url, apiKey)
|
||||
_currentServer.update {
|
||||
|
|
@ -86,47 +116,127 @@ class SeerrServerRepository
|
|||
}
|
||||
server?.server?.let { server ->
|
||||
serverRepository.currentUser.value?.let { jellyfinUser ->
|
||||
val response =
|
||||
when (authMethod) {
|
||||
SeerrAuthMethod.LOCAL -> {
|
||||
seerrApi.api.authApi.authLocalPostWithHttpInfo(
|
||||
AuthLocalPostRequest(
|
||||
email = username,
|
||||
password = password,
|
||||
),
|
||||
)
|
||||
}
|
||||
// TODO Need to update server early so that cookies are saved
|
||||
seerrApi.update(server.url, null)
|
||||
login(seerrApi.api, authMethod, username, password)
|
||||
|
||||
SeerrAuthMethod.JELLYFIN -> {
|
||||
seerrApi.api.authApi.authJellyfinPostWithHttpInfo(
|
||||
AuthJellyfinPostRequest(
|
||||
username = username,
|
||||
password = password,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
SeerrAuthMethod.API_KEY -> {
|
||||
throw IllegalArgumentException("Cannot authenticate with API key using this function")
|
||||
}
|
||||
}
|
||||
response.headers["Cookies"]
|
||||
// TODO get session cookie
|
||||
val user =
|
||||
SeerrUser(
|
||||
jellyfinRowId = jellyfinUser.rowId,
|
||||
jellyfinUserRowId = jellyfinUser.rowId,
|
||||
serverId = server.id,
|
||||
authMethod = SeerrAuthMethod.API_KEY,
|
||||
authMethod = authMethod,
|
||||
username = username,
|
||||
password = password,
|
||||
credential = null,
|
||||
)
|
||||
seerrServerDao.addUser(user)
|
||||
_currentServer.update {
|
||||
_currentUser.update {
|
||||
user
|
||||
}
|
||||
server
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CurrentSeerr(
|
||||
val server: SeerrServer,
|
||||
val user: SeerrUser,
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
Timber.v("Launching")
|
||||
context as AppCompatActivity
|
||||
context.lifecycleScope.launchIO {
|
||||
serverRepository.currentUser.asFlow().collect { user ->
|
||||
Timber.v("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)
|
||||
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
|
||||
}
|
||||
}
|
||||
seerrServerRepository.set(server, seerrUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
|
|
@ -58,6 +59,7 @@ import com.github.damontecres.wholphin.ui.playSoundOnFocus
|
|||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
|
||||
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -84,6 +86,8 @@ fun PreferencesContent(
|
|||
|
||||
val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
|
||||
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
|
||||
val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false)
|
||||
var showSeerrServerDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.preferenceDataStore.data.collect {
|
||||
|
|
@ -381,6 +385,22 @@ fun PreferencesContent(
|
|||
)
|
||||
}
|
||||
|
||||
AppPreference.SeerrIntegration -> {
|
||||
ClickPreference(
|
||||
title = stringResource(pref.title),
|
||||
onClick = { showSeerrServerDialog = true },
|
||||
modifier = Modifier,
|
||||
summary =
|
||||
if (seerrIntegrationEnabled) {
|
||||
stringResource(R.string.enabled)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onLongClick = {},
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val value = pref.getter.invoke(preferences)
|
||||
ComposablePreference(
|
||||
|
|
@ -440,6 +460,11 @@ fun PreferencesContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
if (showSeerrServerDialog) {
|
||||
AddSeerServerDialog(
|
||||
onDismissRequest = { showSeerrServerDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asFlow
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.NavDrawerItemRepository
|
||||
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||
|
|
@ -17,6 +18,7 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles
|
|||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||
|
|
@ -25,6 +27,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
|
|||
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
|
|
@ -43,6 +46,7 @@ class PreferencesViewModel
|
|||
private val serverRepository: ServerRepository,
|
||||
private val navDrawerItemRepository: NavDrawerItemRepository,
|
||||
private val serverPreferencesDao: ServerPreferencesDao,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val deviceInfo: DeviceInfo,
|
||||
private val clientInfo: ClientInfo,
|
||||
) : ViewModel(),
|
||||
|
|
@ -52,6 +56,11 @@ class PreferencesViewModel
|
|||
|
||||
val currentUser get() = serverRepository.currentUser
|
||||
|
||||
val seerrEnabled =
|
||||
seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser ->
|
||||
seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
|||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -54,7 +53,7 @@ class SeerrDiscoverViewModel
|
|||
sort = MediaApi.SortMediaGet.MEDIA_ADDED,
|
||||
).results
|
||||
.orEmpty()
|
||||
Timber.v(tv.firstOrNull()?.jellyfinMediaId)
|
||||
// Timber.v(tv.firstOrNull()?.jellyfinMediaId)
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val movies = seerrService.discoverMovies()
|
||||
|
|
|
|||
|
|
@ -31,14 +31,15 @@ import com.github.damontecres.wholphin.ui.components.EditTextBox
|
|||
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
|
||||
@Composable
|
||||
fun AddSeerrServerContent(
|
||||
fun AddSeerrServerApiKey(
|
||||
onSubmit: (url: String, apiKey: String) -> Unit,
|
||||
errorMessage: String?,
|
||||
status: LoadingState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var error by remember(errorMessage) { mutableStateOf(errorMessage) }
|
||||
var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
|
|
@ -135,3 +136,141 @@ fun AddSeerrServerContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddSeerrServerUsername(
|
||||
onSubmit: (url: String, username: String, password: String) -> Unit,
|
||||
username: String,
|
||||
status: LoadingState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.focusGroup()
|
||||
.padding(16.dp)
|
||||
.wrapContentSize(),
|
||||
) {
|
||||
var url by remember { mutableStateOf("") }
|
||||
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() }
|
||||
Text(
|
||||
text = "Enter URL, username, & password",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
) {
|
||||
Text(
|
||||
text = "URL",
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
EditTextBox(
|
||||
value = url,
|
||||
onValueChange = {
|
||||
error = null
|
||||
url = it
|
||||
},
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onNext = {
|
||||
usernameFocusRequester.tryRequestFocus()
|
||||
},
|
||||
),
|
||||
isInputValid = { true },
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
) {
|
||||
Text(
|
||||
text = "Username",
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
EditTextBox(
|
||||
value = username,
|
||||
onValueChange = {
|
||||
error = null
|
||||
username = it
|
||||
},
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Next,
|
||||
),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onNext = {
|
||||
passwordFocusRequester.tryRequestFocus()
|
||||
},
|
||||
),
|
||||
isInputValid = { true },
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
) {
|
||||
Text(
|
||||
text = "Password",
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
EditTextBox(
|
||||
value = password,
|
||||
onValueChange = {
|
||||
error = null
|
||||
password = it
|
||||
},
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onGo = { onSubmit.invoke(url, username, password) },
|
||||
),
|
||||
isInputValid = { true },
|
||||
modifier = Modifier.focusRequester(passwordFocusRequester),
|
||||
)
|
||||
}
|
||||
error?.let {
|
||||
Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
stringRes = R.string.submit,
|
||||
onClick = { onSubmit.invoke(url, username, password) },
|
||||
enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() && password.isNotNullOrBlank(),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
package com.github.damontecres.wholphin.ui.setup.seerr
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
||||
import com.github.damontecres.wholphin.ui.components.BasicDialog
|
||||
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.util.LoadingState
|
||||
|
||||
@Composable
|
||||
fun AddSeerServerDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
viewModel: SwitchSeerrViewModel = hiltViewModel(),
|
||||
) {
|
||||
val currentUser by viewModel.currentUser.observeAsState()
|
||||
var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) }
|
||||
val status by viewModel.serverConnectionStatus.collectAsState(LoadingState.Pending)
|
||||
LaunchedEffect(status) {
|
||||
if (status is LoadingState.Success) {
|
||||
onDismissRequest.invoke()
|
||||
}
|
||||
}
|
||||
when (val auth = authMethod) {
|
||||
SeerrAuthMethod.LOCAL,
|
||||
SeerrAuthMethod.JELLYFIN,
|
||||
-> {
|
||||
BasicDialog(
|
||||
onDismissRequest = { authMethod = null },
|
||||
) {
|
||||
AddSeerrServerUsername(
|
||||
onSubmit = { url, username, password ->
|
||||
viewModel.submitServer(
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
auth,
|
||||
)
|
||||
},
|
||||
username = currentUser?.name ?: "",
|
||||
status = status,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SeerrAuthMethod.API_KEY -> {
|
||||
BasicDialog(
|
||||
onDismissRequest = { authMethod = null },
|
||||
) {
|
||||
AddSeerrServerApiKey(
|
||||
onSubmit = { url, apiKey -> viewModel.submitServer(url, apiKey) },
|
||||
status = status,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
null -> {
|
||||
ChooseSeerrLoginType(
|
||||
onDismissRequest = onDismissRequest,
|
||||
onChoose = { authMethod = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChooseSeerrLoginType(
|
||||
onDismissRequest: () -> Unit,
|
||||
onChoose: (SeerrAuthMethod) -> Unit,
|
||||
) {
|
||||
val params =
|
||||
remember {
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = "Login to Seerr server",
|
||||
items =
|
||||
listOf(
|
||||
DialogItem(
|
||||
text = "API Key",
|
||||
onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) },
|
||||
),
|
||||
DialogItem(
|
||||
text = "Jellyfin user",
|
||||
onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) },
|
||||
),
|
||||
DialogItem(
|
||||
text = "Local user",
|
||||
onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
DialogPopup(
|
||||
params = params,
|
||||
onDismissRequest = onDismissRequest,
|
||||
dismissOnClick = false,
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,18 @@
|
|||
package com.github.damontecres.wholphin.ui.setup.seerr
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.util.LoadingState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import jakarta.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
@HiltViewModel
|
||||
class SwitchSeerrViewModel
|
||||
|
|
@ -12,10 +20,65 @@ class SwitchSeerrViewModel
|
|||
constructor(
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val seerrService: SeerrService,
|
||||
private val serverRepository: ServerRepository,
|
||||
) : ViewModel() {
|
||||
val currentUser = serverRepository.currentUser
|
||||
|
||||
val serverConnectionStatus = MutableStateFlow<LoadingState>(LoadingState.Pending)
|
||||
|
||||
private fun cleanUrl(url: String) =
|
||||
if (!url.endsWith("/api/v1")) {
|
||||
url
|
||||
.toHttpUrlOrNull()
|
||||
?.newBuilder()
|
||||
?.apply {
|
||||
addPathSegment("api")
|
||||
addPathSegment("v1")
|
||||
}?.build()
|
||||
.toString()
|
||||
} else {
|
||||
url
|
||||
}
|
||||
|
||||
fun submitServer(
|
||||
url: String,
|
||||
apiKey: String,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
val url = cleanUrl(url)
|
||||
val result =
|
||||
seerrServerRepository.testConnection(
|
||||
authMethod = SeerrAuthMethod.API_KEY,
|
||||
url = url,
|
||||
username = null,
|
||||
passwordOrApiKey = apiKey,
|
||||
)
|
||||
if (result is LoadingState.Success) {
|
||||
seerrServerRepository.addAndChangeServer(url, apiKey)
|
||||
}
|
||||
serverConnectionStatus.update { result }
|
||||
}
|
||||
}
|
||||
|
||||
fun submitServer(
|
||||
url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
authMethod: SeerrAuthMethod,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
val url = cleanUrl(url)
|
||||
val result =
|
||||
seerrServerRepository.testConnection(
|
||||
authMethod = authMethod,
|
||||
url = url,
|
||||
username = username,
|
||||
passwordOrApiKey = password,
|
||||
)
|
||||
if (result is LoadingState.Success) {
|
||||
seerrServerRepository.addAndChangeServer(url, authMethod, username, password)
|
||||
}
|
||||
serverConnectionStatus.update { result }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -402,6 +402,7 @@
|
|||
<string name="discover">Discover</string>
|
||||
<string name="request">Request</string>
|
||||
<string name="pending">Pending</string>
|
||||
<string name="seerr_integration">Seerr integration</string>
|
||||
|
||||
<string-array name="theme_song_volume">
|
||||
<item>Disabled</item>
|
||||
|
|
|
|||
|
|
@ -135,9 +135,6 @@ components:
|
|||
readOnly: true
|
||||
required:
|
||||
- id
|
||||
- email
|
||||
- createdAt
|
||||
- updatedAt
|
||||
UserSettings:
|
||||
type: object
|
||||
properties:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue