Basic login UI flow

This commit is contained in:
Damontecres 2025-12-23 16:16:03 -05:00
parent a2884a7225
commit 6046799b91
No known key found for this signature in database
17 changed files with 577 additions and 69 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 20, "version": 20,
"identityHash": "4385c87cd6f510f285ec9802cff1ce2d", "identityHash": "dea51adcc724179afa0174d775f97480",
"entities": [ "entities": [
{ {
"tableName": "servers", "tableName": "servers",
@ -427,7 +427,7 @@
}, },
{ {
"tableName": "seerr_servers", "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": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -435,17 +435,17 @@
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": true "notNull": true
}, },
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{ {
"fieldPath": "url", "fieldPath": "url",
"columnName": "url", "columnName": "url",
"affinity": "TEXT", "affinity": "TEXT",
"notNull": true "notNull": true
}, },
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT"
},
{ {
"fieldPath": "version", "fieldPath": "version",
"columnName": "version", "columnName": "version",
@ -472,11 +472,11 @@
}, },
{ {
"tableName": "seerr_users", "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": [ "fields": [
{ {
"fieldPath": "jellyfinRowId", "fieldPath": "jellyfinUserRowId",
"columnName": "jellyfinRowId", "columnName": "jellyfinUserRowId",
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": true "notNull": true
}, },
@ -511,7 +511,7 @@
"primaryKey": { "primaryKey": {
"autoGenerate": false, "autoGenerate": false,
"columnNames": [ "columnNames": [
"jellyfinRowId", "jellyfinUserRowId",
"serverId" "serverId"
] ]
}, },
@ -532,7 +532,7 @@
"onDelete": "CASCADE", "onDelete": "CASCADE",
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"columns": [ "columns": [
"jellyfinRowId" "jellyfinUserRowId"
], ],
"referencedColumns": [ "referencedColumns": [
"rowId" "rowId"
@ -543,7 +543,7 @@
], ],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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')"
] ]
} }
} }

View file

@ -36,6 +36,7 @@ import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.AppUpgradeHandler import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService 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.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager 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.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.services.UpdateChecker 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.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
@ -97,6 +99,12 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var refreshRateService: RefreshRateService lateinit var refreshRateService: RefreshRateService
@Inject
lateinit var datePlayedInvalidationService: DatePlayedInvalidationService
@Inject
lateinit var userSwitchListener: UserSwitchListener
private var signInAuto = true private var signInAuto = true
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)

View file

@ -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.api.seerr.infrastructure.ApiClient
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import okhttp3.Call import okhttp3.Call
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
class SeerrApiClient( class SeerrApiClient(
val baseUrl: String, val baseUrl: String,
val apiKey: String?, private val apiKey: String?,
okHttpClient: OkHttpClient, okHttpClient: OkHttpClient,
) { ) {
private val cookieJar = SeerrCookieJar()
private val client = private val client =
okHttpClient okHttpClient
.newBuilder() .newBuilder()
.cookieJar(cookieJar)
.addInterceptor { .addInterceptor {
Timber.d("SeerrApiClient: ${it.request().method} ${it.request().url}") Timber.d("SeerrApiClient: ${it.request().method} ${it.request().url}")
it.proceed( it.proceed(
@ -26,6 +33,11 @@ class SeerrApiClient(
) )
}.build() }.build()
val hasValidCredentials: Boolean
get() =
apiKey.isNotNullOrBlank() ||
cookieJar.hasValidCredentials(baseUrl)
private fun <T : ApiClient> create(initializer: (String, Call.Factory) -> T): Lazy<T> = private fun <T : ApiClient> create(initializer: (String, Call.Factory) -> T): Lazy<T> =
lazy { lazy {
initializer.invoke(baseUrl, client) initializer.invoke(baseUrl, client)
@ -50,3 +62,26 @@ class SeerrApiClient(
val usersApi by create(::UsersApi) val usersApi by create(::UsersApi)
val watchlistApi by create(::WatchlistApi) 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
}

View file

@ -9,7 +9,6 @@ import androidx.room.Update
import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrServerUsers import com.github.damontecres.wholphin.data.model.SeerrServerUsers
import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.data.model.SeerrUser
import java.util.UUID
@Dao @Dao
interface SeerrServerDao { interface SeerrServerDao {
@ -32,22 +31,25 @@ interface SeerrServerDao {
suspend fun updateUser(user: SeerrUser) = addUser(user) 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( suspend fun getUser(
serverId: Int, serverId: Int,
userId: UUID, jellyfinUserRowId: Int,
): SeerrUser? ): SeerrUser?
@Query("SELECT * FROM seerr_users WHERE jellyfinUserRowId = :jellyfinUserRowId")
suspend fun getUsersByJellyfinUser(jellyfinUserRowId: Int): List<SeerrUser>
@Query("DELETE FROM seerr_servers WHERE id = :serverId") @Query("DELETE FROM seerr_servers WHERE id = :serverId")
suspend fun deleteServer(serverId: Int) 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( suspend fun deleteUser(
serverId: Int, 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 @Transaction
@Query("SELECT * FROM seerr_servers") @Query("SELECT * FROM seerr_servers")

View file

@ -38,14 +38,14 @@ data class SeerrServer(
ForeignKey( ForeignKey(
entity = JellyfinUser::class, entity = JellyfinUser::class,
parentColumns = arrayOf("rowId"), parentColumns = arrayOf("rowId"),
childColumns = arrayOf("jellyfinRowId"), childColumns = arrayOf("jellyfinUserRowId"),
onDelete = ForeignKey.CASCADE, onDelete = ForeignKey.CASCADE,
), ),
], ],
primaryKeys = ["jellyfinRowId", "serverId"], primaryKeys = ["jellyfinUserRowId", "serverId"],
) )
data class SeerrUser( data class SeerrUser(
val jellyfinRowId: Int, val jellyfinUserRowId: Int,
val serverId: Int, val serverId: Int,
val authMethod: SeerrAuthMethod, val authMethod: SeerrAuthMethod,
val username: String?, val username: String?,
@ -53,7 +53,7 @@ data class SeerrUser(
val credential: String?, val credential: String?,
) { ) {
override fun toString(): 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 { enum class SeerrAuthMethod {

View file

@ -842,6 +842,13 @@ sealed interface AppPreference<Pref, T> {
summaryOn = R.string.enabled, summaryOn = R.string.enabled,
summaryOff = R.string.disabled, 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, title = R.string.more,
preferences = preferences =
listOf( listOf(
AppPreference.SeerrIntegration,
AppPreference.AdvancedSettings, AppPreference.AdvancedSettings,
), ),
), ),

View file

@ -1,7 +1,6 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -11,18 +10,20 @@ class SeerrApi(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
) { ) {
private val prefs: SharedPreferences = // private val prefs: SharedPreferences =
context.getSharedPreferences("seerr", Context.MODE_PRIVATE) // context.getSharedPreferences("seerr", Context.MODE_PRIVATE)
var api: SeerrApiClient = var api: SeerrApiClient =
SeerrApiClient( SeerrApiClient(
prefs.getString("baseUrl", null)!! + "/api/v1", // prefs.getString("baseUrl", null)!! + "/api/v1",
prefs.getString("apiKey", null)!!, // prefs.getString("apiKey", null)!!,
okHttpClient, baseUrl = "",
apiKey = null,
okHttpClient = okHttpClient,
) )
private set private set
val active: Boolean get() = api.baseUrl.isNotNullOrBlank() && api.apiKey.isNotNullOrBlank() val active: Boolean get() = api.baseUrl.isNotNullOrBlank()
fun update( fun update(
baseUrl: String, baseUrl: String,

View file

@ -1,16 +1,29 @@
package com.github.damontecres.wholphin.services 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.AuthJellyfinPostRequest
import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest 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.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrServer
import com.github.damontecres.wholphin.data.model.SeerrUser 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -21,6 +34,7 @@ class SeerrServerRepository
private val seerrApi: SeerrApi, private val seerrApi: SeerrApi,
private val seerrServerDao: SeerrServerDao, private val seerrServerDao: SeerrServerDao,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) { ) {
private val _currentServer = MutableStateFlow<SeerrServer?>(null) private val _currentServer = MutableStateFlow<SeerrServer?>(null)
val currentServer: StateFlow<SeerrServer?> = _currentServer 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( suspend fun addAndChangeServer(
url: String, url: String,
apiKey: String, apiKey: String,
@ -51,7 +83,7 @@ class SeerrServerRepository
// TODO test api key // TODO test api key
val user = val user =
SeerrUser( SeerrUser(
jellyfinRowId = jellyfinUser.rowId, jellyfinUserRowId = jellyfinUser.rowId,
serverId = server.id, serverId = server.id,
authMethod = SeerrAuthMethod.API_KEY, authMethod = SeerrAuthMethod.API_KEY,
username = null, username = null,
@ -59,8 +91,6 @@ class SeerrServerRepository
credential = apiKey, credential = apiKey,
) )
seerrServerDao.addUser(user) seerrServerDao.addUser(user)
// TODO user info
seerrApi.api.usersApi.authMeGet()
seerrApi.update(server.url, apiKey) seerrApi.update(server.url, apiKey)
_currentServer.update { _currentServer.update {
@ -86,47 +116,127 @@ class SeerrServerRepository
} }
server?.server?.let { server -> server?.server?.let { server ->
serverRepository.currentUser.value?.let { jellyfinUser -> serverRepository.currentUser.value?.let { jellyfinUser ->
val response = // TODO Need to update server early so that cookies are saved
when (authMethod) { seerrApi.update(server.url, null)
SeerrAuthMethod.LOCAL -> { login(seerrApi.api, authMethod, username, password)
seerrApi.api.authApi.authLocalPostWithHttpInfo(
AuthLocalPostRequest(
email = username,
password = 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 = val user =
SeerrUser( SeerrUser(
jellyfinRowId = jellyfinUser.rowId, jellyfinUserRowId = jellyfinUser.rowId,
serverId = server.id, serverId = server.id,
authMethod = SeerrAuthMethod.API_KEY, authMethod = authMethod,
username = username, username = username,
password = password, password = password,
credential = null, 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( data class CurrentSeerr(
val server: SeerrServer, val server: SeerrServer,
val user: SeerrUser, 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)
}
}
}
}
}
}
}

View file

@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations import com.github.damontecres.wholphin.data.Migrations
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao 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.data.ServerPreferencesDao
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
@ -61,6 +62,10 @@ object DatabaseModule {
@Singleton @Singleton
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao() fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
@Provides
@Singleton
fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao()
@Provides @Provides
@Singleton @Singleton
fun userPreferencesDataStore( fun userPreferencesDataStore(

View file

@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf 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.SubtitleSettings
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage
import com.github.damontecres.wholphin.ui.setup.UpdateViewModel 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.showToast
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -84,6 +86,8 @@ fun PreferencesContent(
val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf())
var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) }
val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false)
var showSeerrServerDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.preferenceDataStore.data.collect { 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 -> { else -> {
val value = pref.getter.invoke(preferences) val value = pref.getter.invoke(preferences)
ComposablePreference( ComposablePreference(
@ -440,6 +460,11 @@ fun PreferencesContent(
) )
} }
} }
if (showSeerrServerDialog) {
AddSeerServerDialog(
onDismissRequest = { showSeerrServerDialog = false },
)
}
} }
} }

View file

@ -4,6 +4,7 @@ import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.NavDrawerItemRepository
import com.github.damontecres.wholphin.data.ServerPreferencesDao 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.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager 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.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem 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 com.github.damontecres.wholphin.util.RememberTabManager
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.combine
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.DeviceInfo
@ -43,6 +46,7 @@ class PreferencesViewModel
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val navDrawerItemRepository: NavDrawerItemRepository, private val navDrawerItemRepository: NavDrawerItemRepository,
private val serverPreferencesDao: ServerPreferencesDao, private val serverPreferencesDao: ServerPreferencesDao,
private val seerrServerRepository: SeerrServerRepository,
private val deviceInfo: DeviceInfo, private val deviceInfo: DeviceInfo,
private val clientInfo: ClientInfo, private val clientInfo: ClientInfo,
) : ViewModel(), ) : ViewModel(),
@ -52,6 +56,11 @@ class PreferencesViewModel
val currentUser get() = serverRepository.currentUser val currentUser get() = serverRepository.currentUser
val seerrEnabled =
seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser ->
seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId
}
init { init {
viewModelScope.launchIO { viewModelScope.launchIO {
serverRepository.currentUser.value?.let { user -> serverRepository.currentUser.value?.let { user ->

View file

@ -28,7 +28,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -54,7 +53,7 @@ class SeerrDiscoverViewModel
sort = MediaApi.SortMediaGet.MEDIA_ADDED, sort = MediaApi.SortMediaGet.MEDIA_ADDED,
).results ).results
.orEmpty() .orEmpty()
Timber.v(tv.firstOrNull()?.jellyfinMediaId) // Timber.v(tv.firstOrNull()?.jellyfinMediaId)
} }
viewModelScope.launchIO { viewModelScope.launchIO {
val movies = seerrService.discoverMovies() val movies = seerrService.discoverMovies()

View file

@ -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.components.TextButton
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
@Composable @Composable
fun AddSeerrServerContent( fun AddSeerrServerApiKey(
onSubmit: (url: String, apiKey: String) -> Unit, onSubmit: (url: String, apiKey: String) -> Unit,
errorMessage: String?, status: LoadingState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var error by remember(errorMessage) { mutableStateOf(errorMessage) } var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) }
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = 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),
)
}
}

View file

@ -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,
)
}

View file

@ -1,10 +1,18 @@
package com.github.damontecres.wholphin.ui.setup.seerr package com.github.damontecres.wholphin.ui.setup.seerr
import androidx.lifecycle.ViewModel 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.SeerrServerRepository
import com.github.damontecres.wholphin.services.SeerrService 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 dagger.hilt.android.lifecycle.HiltViewModel
import jakarta.inject.Inject import jakarta.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@HiltViewModel @HiltViewModel
class SwitchSeerrViewModel class SwitchSeerrViewModel
@ -12,10 +20,65 @@ class SwitchSeerrViewModel
constructor( constructor(
private val seerrServerRepository: SeerrServerRepository, private val seerrServerRepository: SeerrServerRepository,
private val seerrService: SeerrService, private val seerrService: SeerrService,
private val serverRepository: ServerRepository,
) : ViewModel() { ) : 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( fun submitServer(
url: String, url: String,
apiKey: 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 }
}
} }
} }

View file

@ -402,6 +402,7 @@
<string name="discover">Discover</string> <string name="discover">Discover</string>
<string name="request">Request</string> <string name="request">Request</string>
<string name="pending">Pending</string> <string name="pending">Pending</string>
<string name="seerr_integration">Seerr integration</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>

View file

@ -135,9 +135,6 @@ components:
readOnly: true readOnly: true
required: required:
- id - id
- email
- createdAt
- updatedAt
UserSettings: UserSettings:
type: object type: object
properties: properties: