Fixes & improvements to Jellyseer integration (#783)

## Description
Fixes several bugs and makes it easier to log in to a Jellyseer instance

- Fix initial focus & next focus on the add server dialog
- Don't use an API key for non-API key auth (credit to @voc0der in #734
for suggesting this)
- Show trailers for TV shows
- Fix incorrect/missing similar and recommended rows for both movies &
TV shows
- Allow empty password for Jellyfin users without a password

Log in will now try variations of the input URL such as add the protocol
and/or default port. This is similar to the regular Jellyfin login
process.

### Related issues
Fixes #778
Fixes #750
Fixes #740
Might help with #747
Fixes
https://github.com/damontecres/Wholphin/issues/764#issuecomment-3801324648
This commit is contained in:
Ray 2026-01-27 11:44:56 -05:00 committed by GitHub
parent 3428f2b208
commit e93100c788
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 234 additions and 96 deletions

View file

@ -30,6 +30,7 @@ import okhttp3.OkHttpClient
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
/** /**
* Manages saves/loading Seerr servers from the local DB. Also will update the current [SeerrApi] as needed. * Manages saves/loading Seerr servers from the local DB. Also will update the current [SeerrApi] as needed.
@ -137,7 +138,17 @@ class SeerrServerRepository
username: String?, username: String?,
passwordOrApiKey: String, passwordOrApiKey: String,
): LoadingState { ): LoadingState {
val api = SeerrApiClient(url, passwordOrApiKey, okHttpClient) val apiKey = passwordOrApiKey.takeIf { authMethod == SeerrAuthMethod.API_KEY }
val api =
SeerrApiClient(
url,
apiKey,
okHttpClient
.newBuilder()
.connectTimeout(5.seconds)
.readTimeout(6.seconds)
.build(),
)
login(api, authMethod, username, passwordOrApiKey) login(api, authMethod, username, passwordOrApiKey)
return LoadingState.Success return LoadingState.Success
} }

View file

@ -90,7 +90,7 @@ fun DiscoverMovieDetails(
val people by viewModel.people.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf()) val trailers by viewModel.trailers.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf())
val recommended by viewModel.similar.observeAsState(listOf()) val recommended by viewModel.recommended.observeAsState(listOf())
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val userConfig by viewModel.userConfig.collectAsState(null) val userConfig by viewModel.userConfig.collectAsState(null)
val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) val request4kEnabled by viewModel.request4kEnabled.collectAsState(false)
@ -365,7 +365,7 @@ fun DiscoverMovieDetailsContent(
item { item {
ItemRow( ItemRow(
title = stringResource(R.string.recommended), title = stringResource(R.string.recommended),
items = similar, items = recommended,
onClickItem = { index, item -> onClickItem = { index, item ->
position = RECOMMENDED_ROW position = RECOMMENDED_ROW
onClickItem.invoke(index, item) onClickItem.invoke(index, item)

View file

@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import timber.log.Timber
@HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class) @HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class)
class DiscoverMovieViewModel class DiscoverMovieViewModel
@ -67,8 +68,8 @@ class DiscoverMovieViewModel
val trailers = MutableLiveData<List<Trailer>>(listOf()) val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<DiscoverItem>>(listOf()) val people = MutableLiveData<List<DiscoverItem>>(listOf())
val similar = MutableLiveData<List<DiscoverItem>>(listOf()) val similar = MutableLiveData<List<DiscoverItem>>()
val recommended = MutableLiveData<List<DiscoverItem>>(listOf()) val recommended = MutableLiveData<List<DiscoverItem>>()
val canCancelRequest = MutableStateFlow(false) val canCancelRequest = MutableStateFlow(false)
val userConfig = seerrServerRepository.current.map { it?.config } val userConfig = seerrServerRepository.current.map { it?.config }
@ -99,6 +100,7 @@ class DiscoverMovieViewModel
"Error fetching movie", "Error fetching movie",
), ),
) { ) {
Timber.v("Init for movie %s", item.id)
val movie = fetchAndSetItem().await() val movie = fetchAndSetItem().await()
val discoveredItem = DiscoverItem(movie) val discoveredItem = DiscoverItem(movie)
backdropService.submit(discoveredItem) backdropService.submit(discoveredItem)
@ -116,7 +118,7 @@ class DiscoverMovieViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
seerrService.api.moviesApi seerrService.api.moviesApi
.movieMovieIdSimilarGet(movieId = item.id, page = 2) .movieMovieIdSimilarGet(movieId = item.id, page = 1)
.results .results
?.map(::DiscoverItem) ?.map(::DiscoverItem)
.orEmpty() .orEmpty()
@ -125,11 +127,11 @@ class DiscoverMovieViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
seerrService.api.moviesApi seerrService.api.moviesApi
.movieMovieIdRecommendationsGet(movieId = item.id, page = 2) .movieMovieIdRecommendationsGet(movieId = item.id, page = 1)
.results .results
?.map(::DiscoverItem) ?.map(::DiscoverItem)
.orEmpty() .orEmpty()
similar.setValueOnMain(result) recommended.setValueOnMain(result)
} }
} }
val people = val people =
@ -147,7 +149,7 @@ class DiscoverMovieViewModel
?.filter { it.type == RelatedVideo.Type.TRAILER } ?.filter { it.type == RelatedVideo.Type.TRAILER }
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
?.map { ?.map {
RemoteTrailer(it.name!!, it.url!!, null) RemoteTrailer(it.name!!, it.url!!, it.site)
}.orEmpty() }.orEmpty()
this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers) this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers)
} }

View file

@ -89,6 +89,7 @@ fun DiscoverSeriesDetails(
val item by viewModel.tvSeries.observeAsState() val item by viewModel.tvSeries.observeAsState()
val seasons by viewModel.seasons.observeAsState(listOf()) val seasons by viewModel.seasons.observeAsState(listOf())
val people by viewModel.people.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf())
val recommended by viewModel.recommended.observeAsState(listOf()) val recommended by viewModel.recommended.observeAsState(listOf())
val userConfig by viewModel.userConfig.collectAsState(null) val userConfig by viewModel.userConfig.collectAsState(null)
@ -155,7 +156,7 @@ fun DiscoverSeriesDetails(
trailerOnClick = { trailerOnClick = {
TrailerService.onClick(context, it, viewModel::navigateTo) TrailerService.onClick(context, it, viewModel::navigateTo)
}, },
trailers = listOf(), trailers = trailers,
requestOnClick = { requestOnClick = {
item.id?.let { id -> item.id?.let { id ->
if (request4kEnabled) { if (request4kEnabled) {
@ -255,7 +256,7 @@ fun DiscoverSeriesDetailsContent(
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
var position by rememberInt() var position by rememberInt()
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } }
val playFocusRequester = remember { FocusRequester() } val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequesters.getOrNull(position)?.tryRequestFocus() focusRequesters.getOrNull(position)?.tryRequestFocus()
@ -398,7 +399,7 @@ fun DiscoverSeriesDetailsContent(
item { item {
ItemRow( ItemRow(
title = stringResource(R.string.recommended), title = stringResource(R.string.recommended),
items = similar, items = recommended,
onClickItem = { index, item -> onClickItem = { index, item ->
position = RECOMMENDED_ROW position = RECOMMENDED_ROW
onClickItem.invoke(index, item) onClickItem.invoke(index, item)

View file

@ -4,17 +4,20 @@ import android.content.Context
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
import com.github.damontecres.wholphin.api.seerr.model.Season import com.github.damontecres.wholphin.api.seerr.model.Season
import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
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.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
@ -36,6 +39,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import timber.log.Timber
@HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class) @HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class)
class DiscoverSeriesViewModel class DiscoverSeriesViewModel
@ -62,8 +66,8 @@ class DiscoverSeriesViewModel
val seasons = MutableLiveData<List<Season>>(listOf()) val seasons = MutableLiveData<List<Season>>(listOf())
val trailers = MutableLiveData<List<Trailer>>(listOf()) val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<DiscoverItem>>(listOf()) val people = MutableLiveData<List<DiscoverItem>>(listOf())
val similar = MutableLiveData<List<DiscoverItem>>(listOf()) val similar = MutableLiveData<List<DiscoverItem>>()
val recommended = MutableLiveData<List<DiscoverItem>>(listOf()) val recommended = MutableLiveData<List<DiscoverItem>>()
val canCancelRequest = MutableStateFlow(false) val canCancelRequest = MutableStateFlow(false)
val userConfig = seerrServerRepository.current.map { it?.config } val userConfig = seerrServerRepository.current.map { it?.config }
@ -94,6 +98,7 @@ class DiscoverSeriesViewModel
"Error fetching movie", "Error fetching movie",
), ),
) { ) {
Timber.v("Init for tv %s", item.id)
val tv = fetchAndSetItem().await() val tv = fetchAndSetItem().await()
val discoveredItem = DiscoverItem(tv) val discoveredItem = DiscoverItem(tv)
backdropService.submit(discoveredItem) backdropService.submit(discoveredItem)
@ -110,8 +115,8 @@ class DiscoverSeriesViewModel
if (!similar.isInitialized) { if (!similar.isInitialized) {
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
seerrService.api.moviesApi seerrService.api.tvApi
.movieMovieIdSimilarGet(movieId = item.id, page = 2) .tvTvIdSimilarGet(tvId = item.id, page = 1)
.results .results
?.map(::DiscoverItem) ?.map(::DiscoverItem)
.orEmpty() .orEmpty()
@ -119,12 +124,12 @@ class DiscoverSeriesViewModel
} }
viewModelScope.launchIO { viewModelScope.launchIO {
val result = val result =
seerrService.api.moviesApi seerrService.api.tvApi
.movieMovieIdRecommendationsGet(movieId = item.id, page = 2) .tvTvIdRecommendationsGet(tvId = item.id, page = 1)
.results .results
?.map(::DiscoverItem) ?.map(::DiscoverItem)
.orEmpty() .orEmpty()
similar.setValueOnMain(result) recommended.setValueOnMain(result)
} }
} }
val people = val people =
@ -137,6 +142,15 @@ class DiscoverSeriesViewModel
?.map(::DiscoverItem) ?.map(::DiscoverItem)
.orEmpty() .orEmpty()
this@DiscoverSeriesViewModel.people.setValueOnMain(people) this@DiscoverSeriesViewModel.people.setValueOnMain(people)
val trailers =
tv.relatedVideos
?.filter { it.type == RelatedVideo.Type.TRAILER }
?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() }
?.map {
RemoteTrailer(it.name!!, it.url!!, it.site)
}.orEmpty()
this@DiscoverSeriesViewModel.trailers.setValueOnMain(trailers)
} }
fun navigateTo(destination: Destination) { fun navigateTo(destination: Destination) {

View file

@ -43,7 +43,6 @@ import androidx.tv.material3.surfaceColorAtElevation
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.imageLoader import coil3.imageLoader
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
@ -504,13 +503,7 @@ fun PreferencesContent(
AddSeerServerDialog( AddSeerServerDialog(
currentUsername = currentUser?.name, currentUsername = currentUser?.name,
status = status, status = status,
onSubmit = { url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod -> onSubmit = seerrVm::submitServer,
if (method == SeerrAuthMethod.API_KEY) {
seerrVm.submitServer(url, passwordOrApiKey)
} else {
seerrVm.submitServer(url, username ?: "", passwordOrApiKey, method)
}
},
onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, onDismissRequest = { seerrDialogMode = SeerrDialogMode.None },
) )
} }

View file

@ -6,10 +6,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -30,6 +32,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.components.EditTextBox 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
@ -240,7 +243,7 @@ fun AddSeerrServerUsername(
}, },
), ),
isInputValid = { true }, isInputValid = { true },
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(usernameFocusRequester),
) )
} }
Row( Row(
@ -283,14 +286,25 @@ fun AddSeerrServerUsername(
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
) )
} }
TextButton( Button(
stringRes = R.string.submit,
onClick = { onSubmit.invoke(url, username, password) }, onClick = { onSubmit.invoke(url, username, password) },
enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() && password.isNotNullOrBlank(), enabled =
error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() &&
status != LoadingState.Loading,
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
) {
if (status != LoadingState.Loading) {
Text(text = stringResource(R.string.submit))
} else {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier =
Modifier.size(24.dp),
) )
} }
} }
}
}
@PreviewTvSpec @PreviewTvSpec
@Composable @Composable

View file

@ -17,7 +17,7 @@ import com.github.damontecres.wholphin.util.LoadingState
fun AddSeerServerDialog( fun AddSeerServerDialog(
currentUsername: String?, currentUsername: String?,
status: LoadingState, status: LoadingState,
onSubmit: (url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) } var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) }
@ -49,7 +49,7 @@ fun AddSeerServerDialog(
) { ) {
AddSeerrServerApiKey( AddSeerrServerApiKey(
onSubmit = { url, apiKey -> onSubmit = { url, apiKey ->
onSubmit.invoke(url, null, apiKey, SeerrAuthMethod.API_KEY) onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY)
}, },
status = status, status = status,
) )

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.setup.seerr package com.github.damontecres.wholphin.ui.setup.seerr
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
@ -8,18 +9,22 @@ 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.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import jakarta.inject.Inject import jakarta.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import timber.log.Timber import timber.log.Timber
@HiltViewModel @HiltViewModel
class SwitchSeerrViewModel class SwitchSeerrViewModel
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context,
private val seerrServerRepository: SeerrServerRepository, private val seerrServerRepository: SeerrServerRepository,
private val seerrService: SeerrService, private val seerrService: SeerrService,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
@ -28,75 +33,70 @@ class SwitchSeerrViewModel
val serverConnectionStatus = MutableStateFlow<LoadingState>(LoadingState.Pending) 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 =
try {
seerrServerRepository.testConnection(
authMethod = SeerrAuthMethod.API_KEY,
url = url,
username = null,
passwordOrApiKey = apiKey,
)
} catch (ex: ClientException) {
Timber.w(ex, "Error logging in via API Key")
if (ex.statusCode == 401 || ex.statusCode == 403) {
LoadingState.Error("Invalid credentials", ex)
} else {
LoadingState.Error(ex)
}
}
if (result is LoadingState.Success) {
seerrServerRepository.addAndChangeServer(url, apiKey)
}
serverConnectionStatus.update { result }
}
}
fun submitServer( fun submitServer(
url: String, url: String,
username: String, username: String,
password: String, passwordOrApiKey: String,
authMethod: SeerrAuthMethod, authMethod: SeerrAuthMethod,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val url = cleanUrl(url) serverConnectionStatus.update { LoadingState.Loading }
val result = val urls =
try {
createUrls(url)
} catch (ex: IllegalArgumentException) {
showToast(context, "Invalid URL")
serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) }
return@launchIO
}
var result: LoadingState = LoadingState.Error("No url")
for (url in urls) {
Timber.d("Trying %s", url)
result =
try { try {
seerrServerRepository.testConnection( seerrServerRepository.testConnection(
authMethod = authMethod, authMethod = authMethod,
url = url, url = url.toString(),
username = username, username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY },
passwordOrApiKey = password, passwordOrApiKey = passwordOrApiKey,
) )
} catch (ex: ClientException) { } catch (ex: ClientException) {
Timber.w(ex, "Error logging in via %s", authMethod) Timber.w(ex, "Error logging in")
if (ex.statusCode == 401 || ex.statusCode == 403) { if (ex.statusCode == 401 || ex.statusCode == 403) {
showToast(context, "Invalid credentials")
LoadingState.Error("Invalid credentials", ex) LoadingState.Error("Invalid credentials", ex)
break
} else { } else {
LoadingState.Error("Could not connect with URL")
}
} catch (ex: Exception) {
LoadingState.Error(ex) LoadingState.Error(ex)
} }
}
if (result is LoadingState.Success) { if (result is LoadingState.Success) {
seerrServerRepository.addAndChangeServer(url, authMethod, username, password) when (authMethod) {
SeerrAuthMethod.LOCAL,
SeerrAuthMethod.JELLYFIN,
-> {
seerrServerRepository.addAndChangeServer(
url.toString(),
authMethod,
username,
passwordOrApiKey,
)
}
SeerrAuthMethod.API_KEY -> {
seerrServerRepository.addAndChangeServer(
url.toString(),
passwordOrApiKey,
)
}
}
break
}
}
if (result is LoadingState.Error) {
showToast(context, "Error: ${result.message}")
} }
serverConnectionStatus.update { result } serverConnectionStatus.update { result }
} }
@ -112,3 +112,39 @@ class SwitchSeerrViewModel
serverConnectionStatus.update { LoadingState.Pending } serverConnectionStatus.update { LoadingState.Pending }
} }
} }
fun createUrls(url: String): List<HttpUrl> {
val urls = mutableListOf<String>()
if (url.startsWith("http://") || url.startsWith("https://")) {
urls.add(url)
val httpUrl = url.toHttpUrl()
if (HttpUrl.defaultPort(httpUrl.scheme) == httpUrl.port) {
urls.add("$url:5055")
}
} else {
urls.add("http://$url")
val httpUrl = "http://$url".toHttpUrl()
if (httpUrl.port == 80) {
urls.add("https://$url")
urls.add("http://$url:5055")
urls.add("https://$url:5055")
} else {
urls.add("https://$url")
}
}
return urls.map { cleanUrl(it).toHttpUrl() }
}
private fun cleanUrl(url: String) =
if (!url.endsWith("/api/v1")) {
url
.toHttpUrl()
.newBuilder()
.apply {
addPathSegment("api")
addPathSegment("v1")
}.build()
.toString()
} else {
url
}

View file

@ -897,8 +897,6 @@ components:
- Bloopers - Bloopers
site: site:
type: string type: string
enum:
- 'YouTube'
MovieDetails: MovieDetails:
type: object type: object
properties: properties:
@ -1226,6 +1224,10 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/WatchProviders' $ref: '#/components/schemas/WatchProviders'
relatedVideos:
type: array
items:
$ref: '#/components/schemas/RelatedVideo'
MediaRequest: MediaRequest:
type: object type: object
properties: properties:

View file

@ -0,0 +1,65 @@
package com.github.damontecres.wholphin.test
import com.github.damontecres.wholphin.ui.setup.seerr.createUrls
import org.junit.Assert
import org.junit.Test
class TestSeerr {
@Test
fun testCreateUrls() {
val urls =
createUrls("jellyseerr.com")
.map { it.toString() }
val expected =
listOf(
"http://jellyseerr.com/api/v1",
"https://jellyseerr.com/api/v1",
"http://jellyseerr.com:5055/api/v1",
"https://jellyseerr.com:5055/api/v1",
)
Assert.assertEquals(expected, urls)
}
@Test
fun testCreateUrls2() {
val urls =
createUrls("https://jellyseerr.com")
.map { it.toString() }
val expected =
listOf(
"https://jellyseerr.com/api/v1",
"https://jellyseerr.com:5055/api/v1",
)
Assert.assertEquals(expected, urls)
}
@Test
fun testCreateUrls3() {
val urls =
createUrls("http://jellyseerr.com")
.map { it.toString() }
val expected =
listOf(
"http://jellyseerr.com/api/v1",
"http://jellyseerr.com:5055/api/v1",
)
Assert.assertEquals(expected, urls)
}
@Test
fun testCreateUrls4() {
val urls =
createUrls("jellyseerr.com:5055")
.map { it.toString() }
val expected =
listOf(
"http://jellyseerr.com:5055/api/v1",
"https://jellyseerr.com:5055/api/v1",
)
Assert.assertEquals(expected, urls)
}
}