diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json index 9bf751a8..9c365430 100644 --- a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json @@ -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')" ] } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 41d24b3c..638ebc0f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt b/app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt index 941ce821..4a76a00c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt @@ -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 create(initializer: (String, Call.Factory) -> T): Lazy = 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>() + + override fun saveFromResponse( + url: HttpUrl, + cookies: List, + ) { + cookies + .filter { it.name == "connect.sid" } + .groupBy { it.domain } + .forEach { (domain, cookies) -> + this.cookies[domain] = cookies + } + } + + override fun loadForRequest(url: HttpUrl): List = this.cookies[url.host].orEmpty() + + fun hasValidCredentials(baseUrl: String): Boolean = + baseUrl.toHttpUrlOrNull()?.host?.let { domain -> + cookies[domain]?.any { it.expiresAt > System.currentTimeMillis() } + } == true +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt index d432e877..59f5d9ea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt @@ -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 + @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") diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt index 0eb79efd..e29305d2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt @@ -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 { diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 6bca9d38..bc5564f6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -842,6 +842,13 @@ sealed interface AppPreference { summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) + + val SeerrIntegration = + AppClickablePreference( + 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, ), ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt index 2e7cbd30..07415454 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt @@ -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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index 8103cbfc..c4a8370d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -1,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(null) val currentServer: StateFlow = _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) + } + } + } + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index 30fb4422..ed3f761b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -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( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 71eac49f..152178b2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -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 }, + ) + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index d1e2baa2..9973bbec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -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 -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/seerr/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/seerr/SeerrDiscoverPage.kt index 89cd0aee..df701182 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/seerr/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/seerr/SeerrDiscoverPage.kt @@ -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() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt index 1f632a1f..784b7081 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt new file mode 100644 index 00000000..785b59c1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -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(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, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt index b1f890e3..1b694f4b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -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.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 } + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a35963cf..42e5c911 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -402,6 +402,7 @@ Discover Request Pending + Seerr integration Disabled diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index 6636d77c..6ef6ac0f 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -135,9 +135,6 @@ components: readOnly: true required: - id - - email - - createdAt - - updatedAt UserSettings: type: object properties: