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 javax.inject.Inject
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.
@ -137,7 +138,17 @@ class SeerrServerRepository
username: String?,
passwordOrApiKey: String,
): 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)
return LoadingState.Success
}

View file

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

View file

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

View file

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

View file

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

View file

@ -43,7 +43,6 @@ import androidx.tv.material3.surfaceColorAtElevation
import coil3.SingletonImageLoader
import coil3.imageLoader
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.AppPreferences
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
@ -504,13 +503,7 @@ fun PreferencesContent(
AddSeerServerDialog(
currentUsername = currentUser?.name,
status = status,
onSubmit = { url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod ->
if (method == SeerrAuthMethod.API_KEY) {
seerrVm.submitServer(url, passwordOrApiKey)
} else {
seerrVm.submitServer(url, username ?: "", passwordOrApiKey, method)
}
},
onSubmit = seerrVm::submitServer,
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -30,6 +32,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
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.TextButton
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -240,7 +243,7 @@ fun AddSeerrServerUsername(
},
),
isInputValid = { true },
modifier = Modifier.focusRequester(focusRequester),
modifier = Modifier.focusRequester(usernameFocusRequester),
)
}
Row(
@ -283,12 +286,23 @@ fun AddSeerrServerUsername(
style = MaterialTheme.typography.titleLarge,
)
}
TextButton(
stringRes = R.string.submit,
Button(
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),
)
) {
if (status != LoadingState.Loading) {
Text(text = stringResource(R.string.submit))
} else {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier =
Modifier.size(24.dp),
)
}
}
}
}

View file

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

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.setup.seerr
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.SeerrService
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import jakarta.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import timber.log.Timber
@HiltViewModel
class SwitchSeerrViewModel
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val seerrServerRepository: SeerrServerRepository,
private val seerrService: SeerrService,
private val serverRepository: ServerRepository,
@ -28,75 +33,70 @@ class SwitchSeerrViewModel
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(
url: String,
username: String,
password: String,
passwordOrApiKey: String,
authMethod: SeerrAuthMethod,
) {
viewModelScope.launchIO {
val url = cleanUrl(url)
val result =
serverConnectionStatus.update { LoadingState.Loading }
val urls =
try {
seerrServerRepository.testConnection(
authMethod = authMethod,
url = url,
username = username,
passwordOrApiKey = password,
)
} catch (ex: ClientException) {
Timber.w(ex, "Error logging in via %s", authMethod)
if (ex.statusCode == 401 || ex.statusCode == 403) {
LoadingState.Error("Invalid credentials", ex)
} else {
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 {
seerrServerRepository.testConnection(
authMethod = authMethod,
url = url.toString(),
username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY },
passwordOrApiKey = passwordOrApiKey,
)
} catch (ex: ClientException) {
Timber.w(ex, "Error logging in")
if (ex.statusCode == 401 || ex.statusCode == 403) {
showToast(context, "Invalid credentials")
LoadingState.Error("Invalid credentials", ex)
break
} else {
LoadingState.Error("Could not connect with URL")
}
} catch (ex: Exception) {
LoadingState.Error(ex)
}
if (result is LoadingState.Success) {
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.Success) {
seerrServerRepository.addAndChangeServer(url, authMethod, username, password)
}
if (result is LoadingState.Error) {
showToast(context, "Error: ${result.message}")
}
serverConnectionStatus.update { result }
}
@ -112,3 +112,39 @@ class SwitchSeerrViewModel
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
site:
type: string
enum:
- 'YouTube'
MovieDetails:
type: object
properties:
@ -1226,6 +1224,10 @@ components:
type: array
items:
$ref: '#/components/schemas/WatchProviders'
relatedVideos:
type: array
items:
$ref: '#/components/schemas/RelatedVideo'
MediaRequest:
type: object
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)
}
}