From 47e54aa6757710d1789767be9eb5175089d0c668 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 24 Jan 2026 19:23:15 -0500 Subject: [PATCH 1/6] Catch errors on discover request tab (#765) ## Description Handle errors on the Discover/Jellyseerr requests tab preventing crashes Note: this PR fixes the crash, but the error was a 400, which is likely due to the older Jellyseerr version. ### Related issues Fixes #764 --- README.md | 4 +++- .../damontecres/wholphin/ui/discover/SeerrRequestsPage.kt | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 638cb74b..50f01c12 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin ### User interface - A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app -- Integration with [Seerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows +- Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows - Option to combine Continue Watching & Next Up rows - Show Movie/TV Show titles when browsing libraries - Play theme music, if available @@ -93,6 +93,8 @@ Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue! +Jellyseerr integration is tested with `v2.7.3`. Older versions may not work. + ## Contributions Issues and pull requests are always welcome! Please check before submitting that your issue or pull request is not a duplicate. diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt index 6746950a..3a838815 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update @@ -112,6 +113,9 @@ class SeerrRequestsViewModel state.update { it.copy(requests = DataLoadingState.Success(results)) } } + }.catch { ex -> + Timber.e(ex, "Error fetching requests") + state.update { it.copy(requests = DataLoadingState.Error(ex)) } }.launchIn(viewModelScope) } From 7b8f5c3e634e7ba85484e08b3957ad10ff516056 Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Sat, 24 Jan 2026 18:24:23 -0600 Subject: [PATCH 2/6] Fix(player): Disable gpu-next by default (#760) ## Description Disables gpu-next by default in MPV settings. This renderer is experimental and causes broken playback (purple screen) on some older devices like the NVIDIA Shield 2019 Pro. Also adds a migration to ensure gpu-next is turned off for existing users who upgrade, avoiding playback regressions for users being newly moved over to the Prefer MPV settings so that they can experience a smooth transition. ### Related issues Fixes #754? ### Screenshots N/A ### AI/LLM usage PR drafting --- .../damontecres/wholphin/preferences/AppPreference.kt | 2 +- .../damontecres/wholphin/services/AppUpgradeHandler.kt | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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 f1be13a4..99b457a5 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 @@ -783,7 +783,7 @@ sealed interface AppPreference { val MpvGpuNext = AppSwitchPreference( title = R.string.mpv_use_gpu_next, - defaultValue = true, + defaultValue = false, getter = { it.playbackPreferences.mpvOptions.useGpuNext }, setter = { prefs, value -> prefs.updateMpvOptions { useGpuNext = value } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 24410fa0..09d6a11e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -209,4 +209,12 @@ suspend fun upgradeApp( } showToast(context, context.getString(R.string.upgrade_mpv_toast), Toast.LENGTH_LONG) } + + if (previous.isEqualOrBefore(Version.fromString("0.4.0-2-g0"))) { + appPreferences.updateData { + it.updateMpvOptions { + useGpuNext = false + } + } + } } From 03871579f5b755153ec21926bd97e64d16ead78c Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 25 Jan 2026 21:38:41 -0500 Subject: [PATCH 3/6] Fixes MPV crash when playing next up (#772) ## Description Ensure that MPV is always destroyed before being initialized again. Also, now creating a new player is only done when switching backends which means faster, more efficient playing the next up. ### Related issues Fixes #766 --- .../wholphin/ui/playback/PlaybackViewModel.kt | 27 ++++++++++--------- .../damontecres/wholphin/util/mpv/MPVLib.kt | 12 ++------- .../wholphin/util/mpv/MpvPlayer.kt | 18 ++++++++----- 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 0cefdcdf..1951d1d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -219,18 +219,22 @@ class PlaybackViewModel PlayerBackend.PREFER_MPV -> if (isHdr) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV } - Timber.i("Selected backend: %s", playerBackend) - withContext(Dispatchers.Main) { - disconnectPlayer() - } + Timber.d("Selected backend: %s", playerBackend) + if (currentPlayer.value?.backend != playerBackend) { + Timber.i("Switching player backend to %s", playerBackend) + withContext(Dispatchers.Main) { + disconnectPlayer() + } - player = - playerFactory.createVideoPlayer( - playerBackend, - preferences.appPreferences.playbackPreferences, - ) - currentPlayer.update { - PlayerState(player, playerBackend) + player = + playerFactory.createVideoPlayer( + playerBackend, + preferences.appPreferences.playbackPreferences, + ) + currentPlayer.update { + PlayerState(player, playerBackend) + } + configurePlayer() } } @@ -429,7 +433,6 @@ class PlaybackViewModel // Create the correct player for the media createPlayer(videoStream?.hdr == true) - configurePlayer() val subtitleStreams = mediaSource.mediaStreams diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt index e5b902d7..fad62f2b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt @@ -39,17 +39,9 @@ object MPVLib { external fun create(appctx: Context) - fun initialize() { - synchronized(this) { init() } - } + external fun init() - private external fun init() - - fun tearDown() { - synchronized(this) { destroy() } - } - - private external fun destroy() + external fun destroy() external fun attachSurface(surface: Surface) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt index 58c976c4..77613be3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt @@ -74,6 +74,8 @@ class MpvPlayer( SurfaceHolder.Callback { companion object { private const val DEBUG = false + + private val initLock = Any() } private var surface: Surface? = null @@ -131,7 +133,7 @@ class MpvPlayer( MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") Timber.v("Initializing MPVLib") - MPVLib.initialize() + MPVLib.init() MPVLib.setOptionString("force-window", "no") MPVLib.setOptionString("idle", "yes") @@ -1086,14 +1088,18 @@ class MpvPlayer( } MpvCommand.INITIALIZE -> { - init() + synchronized(initLock) { + init() + } } MpvCommand.DESTROY -> { - clearVideoSurfaceView(null) - MPVLib.removeLogObserver(mpvLogger) - MPVLib.tearDown() - Timber.d("MPVLib destroyed") + synchronized(initLock) { + MPVLib.setPropertyBoolean("pause", true) + MPVLib.removeLogObserver(mpvLogger) + MPVLib.destroy() + Timber.d("MPVLib destroyed") + } } } } From 7c985ae2ac771e7e248e828179947c96283e0acf Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:07:27 -0500 Subject: [PATCH 4/6] Fixes not requiring PIN if the app is forced closed (#773) ## Description Checks if a PIN is needed when restoring the previous session ### Related issues Addresses https://github.com/damontecres/Wholphin/issues/321#issuecomment-3797445005 --- .../java/com/github/damontecres/wholphin/MainActivity.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 c7b12bfc..0f8b7ee4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -417,8 +417,12 @@ class MainActivityViewModel prefs.currentUserId?.toUUIDOrNull(), ) if (current != null) { - // Restored - navigationManager.navigateTo(SetupDestination.AppContent(current)) + if (current.user.hasPin) { + navigationManager.navigateTo(SetupDestination.UserList(current.server)) + } else { + // Restored + navigationManager.navigateTo(SetupDestination.AppContent(current)) + } } else { // Did not restore navigationManager.navigateTo(SetupDestination.ServerList) From 3428f2b208ee4f3639c33943aebdac93b5ea5ba4 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:34:50 -0500 Subject: [PATCH 5/6] Fixes possible crash if a home row is removed (#779) ## Description Fixes a possible crash if a row on the home page is removed. This could potentially happen if you only have one thing on the resume row and watch it by accessing it from the bottom row. --- .../com/github/damontecres/wholphin/ui/main/HomePage.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 33d40a4a..d866926d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -62,6 +62,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp @@ -203,15 +204,15 @@ fun HomePageContent( val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } } var firstFocused by remember { mutableStateOf(false) } LaunchedEffect(homeRows) { - if (!firstFocused) { + if (!firstFocused && homeRows.isNotEmpty()) { if (position.row >= 0) { - rowFocusRequesters[position.row].tryRequestFocus() + val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex) + rowFocusRequesters.getOrNull(index)?.tryRequestFocus() firstFocused = true } else { // Waiting for the first home row to load, then focus on it homeRows - .indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } - .takeIf { it >= 0 } + .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } ?.let { rowFocusRequesters[it].tryRequestFocus() firstFocused = true From e93100c7880930dc081e798768483ce781fd55a6 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:44:56 -0500 Subject: [PATCH 6/6] 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 --- .../services/SeerrServerRepository.kt | 13 +- .../detail/discover/DiscoverMovieDetails.kt | 4 +- .../detail/discover/DiscoverMovieViewModel.kt | 14 +- .../detail/discover/DiscoverSeriesDetails.kt | 7 +- .../discover/DiscoverSeriesViewModel.kt | 28 +++- .../ui/preferences/PreferencesContent.kt | 9 +- .../wholphin/ui/setup/seerr/AddSeerrServer.kt | 24 ++- .../ui/setup/seerr/AddSeerrServerDialog.kt | 4 +- .../ui/setup/seerr/SwitchSeerrViewModel.kt | 156 +++++++++++------- app/src/main/seerr/seerr-api.yml | 6 +- .../damontecres/wholphin/test/TestSeerr.kt | 65 ++++++++ 11 files changed, 234 insertions(+), 96 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt 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 e267a12f..5d1ce4d3 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 @@ -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 } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index 9ddf4bc3..3c22e8a9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt index 0883023d..cc80d404 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -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>(listOf()) val people = MutableLiveData>(listOf()) - val similar = MutableLiveData>(listOf()) - val recommended = MutableLiveData>(listOf()) + val similar = MutableLiveData>() + val recommended = MutableLiveData>() 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) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index 2cf93e8b..dcaed1c5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -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) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt index 4803aee4..f7b11137 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -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>(listOf()) val trailers = MutableLiveData>(listOf()) val people = MutableLiveData>(listOf()) - val similar = MutableLiveData>(listOf()) - val recommended = MutableLiveData>(listOf()) + val similar = MutableLiveData>() + val recommended = MutableLiveData>() 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) { 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 597fd963..fcb418b8 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 @@ -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 }, ) } 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 0c74f715..d2b48c35 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 @@ -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), + ) + } + } } } 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 index 0feef21c..3601fdd7 100644 --- 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 @@ -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(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, ) 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 0b067287..6148bfec 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,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.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 { + val urls = mutableListOf() + 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 + } diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index e8c9c685..d619d042 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -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: diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt new file mode 100644 index 00000000..76893f8c --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt @@ -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) + } +}