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/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index b6772df8..be7862de 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -419,8 +419,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) 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 27fd1d5b..9a8643fc 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 @@ -789,7 +789,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 + } + } + } } 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 b28b682b..135ffaf1 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/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) } 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 a218f985..db7f8eb8 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 @@ -64,6 +64,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 @@ -206,15 +207,15 @@ fun HomePageContent( var firstFocused by remember { mutableStateOf(false) } if (takeFocus) { 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 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/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/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") + } } } } 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) + } +}