Merge branch 'main' into fea/customize-home

This commit is contained in:
Damontecres 2026-01-27 11:47:22 -05:00
commit d99c947068
No known key found for this signature in database
20 changed files with 290 additions and 132 deletions

View file

@ -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.

View file

@ -419,8 +419,12 @@ class MainActivityViewModel
prefs.currentUserId?.toUUIDOrNull(),
)
if (current != null) {
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)

View file

@ -789,7 +789,7 @@ sealed interface AppPreference<Pref, T> {
val MpvGpuNext =
AppSwitchPreference<AppPreferences>(
title = R.string.mpv_use_gpu_next,
defaultValue = true,
defaultValue = false,
getter = { it.playbackPreferences.mpvOptions.useGpuNext },
setter = { prefs, value ->
prefs.updateMpvOptions { useGpuNext = value }

View file

@ -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
}
}
}
}

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

@ -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)
}

View file

@ -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

View file

@ -219,7 +219,9 @@ class PlaybackViewModel
PlayerBackend.PREFER_MPV -> if (isHdr) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV
}
Timber.i("Selected backend: %s", playerBackend)
Timber.d("Selected backend: %s", playerBackend)
if (currentPlayer.value?.backend != playerBackend) {
Timber.i("Switching player backend to %s", playerBackend)
withContext(Dispatchers.Main) {
disconnectPlayer()
}
@ -232,6 +234,8 @@ class PlaybackViewModel
currentPlayer.update {
PlayerState(player, playerBackend)
}
configurePlayer()
}
}
private fun configurePlayer() {
@ -429,7 +433,6 @@ class PlaybackViewModel
// Create the correct player for the media
createPlayer(videoStream?.hdr == true)
configurePlayer()
val subtitleStreams =
mediaSource.mediaStreams

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,13 +286,24 @@ 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),
)
}
}
}
}
@PreviewTvSpec

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 {
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,
username = username,
passwordOrApiKey = password,
url = url.toString(),
username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY },
passwordOrApiKey = passwordOrApiKey,
)
} 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) {
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) {
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 }
}
@ -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

@ -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)

View file

@ -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,17 +1088,21 @@ class MpvPlayer(
}
MpvCommand.INITIALIZE -> {
synchronized(initLock) {
init()
}
}
MpvCommand.DESTROY -> {
clearVideoSurfaceView(null)
synchronized(initLock) {
MPVLib.setPropertyBoolean("pause", true)
MPVLib.removeLogObserver(mpvLogger)
MPVLib.tearDown()
MPVLib.destroy()
Timber.d("MPVLib destroyed")
}
}
}
}
}
fun MPVLib.setPropertyColor(

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)
}
}