feat(connectivity): add offline resilience with server reachability checks

This commit is contained in:
2026-06-07 11:25:59 +00:00
parent 3f9fd6e36e
commit 4a6977eefb
11 changed files with 317 additions and 54 deletions

View File

@@ -13,11 +13,11 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
@@ -46,6 +46,7 @@ fun AppScreen(
val nextUp by viewModel.nextUp.collectAsStateWithLifecycle()
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
val isCheckingConnection by viewModel.isCheckingConnection.collectAsStateWithLifecycle()
val isCheckingForUpdates by updateViewModel.isCheckingForUpdates.collectAsStateWithLifecycle()
val availableUpdate by updateViewModel.availableUpdate.collectAsStateWithLifecycle()
val navigationManager = LocalNavigationManager.current
@@ -133,6 +134,8 @@ fun AppScreen(
DownloadsScreen(
selectedTab = selectedTab,
isOnline = isOnline,
isCheckingConnection = isCheckingConnection,
onCheckConnection = viewModel::checkConnection,
onTabSelected = onTabSelected,
modifier = Modifier.fillMaxSize()
)

View File

@@ -10,12 +10,15 @@ import androidx.compose.ui.Modifier
import hu.bbara.purefin.ui.screen.AppBottomBar
import hu.bbara.purefin.ui.screen.download.components.DownloadsContent
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBar
import hu.bbara.purefin.ui.screen.home.components.DefaultTopBarTextButton
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DownloadsScreen(
selectedTab: Int,
isOnline: Boolean,
isCheckingConnection: Boolean,
onCheckConnection: () -> Unit,
onTabSelected: (Int) -> Unit,
modifier: Modifier = Modifier
) {
@@ -23,7 +26,19 @@ fun DownloadsScreen(
modifier = modifier.fillMaxSize(),
containerColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
topBar = { DefaultTopBar() },
topBar = {
DefaultTopBar(
rightActions = {
if (!isOnline) {
DefaultTopBarTextButton(
text = if (isCheckingConnection) "Checking…" else "Check connection",
onClick = onCheckConnection,
enabled = !isCheckingConnection
)
}
}
)
},
bottomBar = {
AppBottomBar(
selectedTab = selectedTab,

View File

@@ -5,14 +5,17 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text

View File

@@ -5,18 +5,23 @@ import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
@@ -114,6 +119,37 @@ fun DefaultTopBarIconButton(
}
}
@Composable
fun DefaultTopBarTextButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val scheme = MaterialTheme.colorScheme
TextButton(
onClick = onClick,
enabled = enabled,
shape = CircleShape,
colors = ButtonDefaults.textButtonColors(
containerColor = scheme.surface,
contentColor = scheme.onSurface,
disabledContainerColor = scheme.surface,
disabledContentColor = scheme.onSurface
),
contentPadding = PaddingValues(horizontal = 18.dp),
modifier = modifier
.height(50.dp)
.clip(CircleShape),
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
)
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun DefaultTopBarSearchButton(

View File

@@ -9,12 +9,13 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.util.UUID
import javax.inject.Inject
@@ -25,12 +26,13 @@ import javax.inject.Singleton
class CompositeLocalMediaRepository @Inject constructor(
@Offline private val offlineRepository: LocalMediaRepository,
@Online private val onlineRepository: LocalMediaRepository,
private val networkMonitor: NetworkMonitor,
) : LocalMediaRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// TODO move this into the domain layer and there you can use NetworkMonitor. Data should be free of android stuff.
private val activeRepository: Flow<LocalMediaRepository> = flowOf(onlineRepository)
private val activeRepository: Flow<LocalMediaRepository> = networkMonitor.isOnline
.map { isOnline -> if (isOnline) onlineRepository else offlineRepository }
override val movies: StateFlow<Map<UUID, Movie>> = activeRepository
.flatMapLatest { it.movies }
@@ -45,40 +47,111 @@ class CompositeLocalMediaRepository @Inject constructor(
.stateIn(scope, Eagerly, emptyMap())
override suspend fun getMovie(id: UUID): Flow<Movie?> {
return activeRepository
.flatMapLatest { it.getMovie(id) }
return getFromActiveRepository(
onlineRead = { onlineRepository.getMovie(id) },
offlineRead = { offlineRepository.getMovie(id) },
)
}
override suspend fun getSeries(id: UUID): Flow<Series?> {
return activeRepository
.flatMapLatest { it.getSeries(id) }
return getFromActiveRepository(
onlineRead = { onlineRepository.getSeries(id) },
offlineRead = { offlineRepository.getSeries(id) },
)
}
override suspend fun getEpisode(id: UUID): Flow<Episode?> {
return activeRepository
.flatMapLatest { it.getEpisode(id) }
return getFromActiveRepository(
onlineRead = { onlineRepository.getEpisode(id) },
offlineRead = { offlineRepository.getEpisode(id) },
)
}
override suspend fun loadSeasons(seriesId: UUID) {
activeRepository.first().loadSeasons(seriesId)
runOnlineOrOfflineNoOp(
onlineAction = { onlineRepository.loadSeasons(seriesId) },
offlineAction = { offlineRepository.loadSeasons(seriesId) },
)
}
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {
activeRepository.first().loadSeasonEpisodes(seriesId, seasonId)
runOnlineOrOfflineNoOp(
onlineAction = { onlineRepository.loadSeasonEpisodes(seriesId, seasonId) },
offlineAction = { offlineRepository.loadSeasonEpisodes(seriesId, seasonId) },
)
}
override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
onlineRepository.updateWatchProgress(mediaId, positionMs, durationMs)
if (networkMonitor.isOnline.first()) {
runOnlineAction { onlineRepository.updateWatchProgress(mediaId, positionMs, durationMs) }
}
offlineRepository.updateWatchProgress(mediaId, positionMs, durationMs)
}
override suspend fun updateWatchProgressPercent(mediaId: UUID, progressPercent: Double) {
onlineRepository.updateWatchProgressPercent(mediaId, progressPercent)
if (networkMonitor.isOnline.first()) {
runOnlineAction { onlineRepository.updateWatchProgressPercent(mediaId, progressPercent) }
}
offlineRepository.updateWatchProgressPercent(mediaId, progressPercent)
}
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {
val repository = onlineRepository
repository.markAsWatched(mediaId, watched)
runOnlineOrOfflineNoOp(
onlineAction = { onlineRepository.markAsWatched(mediaId, watched) },
offlineAction = { offlineRepository.markAsWatched(mediaId, watched) },
)
}
private suspend fun <T> getFromActiveRepository(
onlineRead: suspend () -> Flow<T>,
offlineRead: suspend () -> Flow<T>,
): Flow<T> {
if (!networkMonitor.isOnline.first()) {
return offlineRead()
}
return try {
onlineRead()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
if (!networkMonitor.checkConnection()) {
offlineRead()
} else {
throw error
}
}
}
private suspend fun runOnlineOrOfflineNoOp(
onlineAction: suspend () -> Unit,
offlineAction: suspend () -> Unit,
) {
if (!networkMonitor.isOnline.first()) {
offlineAction()
return
}
try {
onlineAction()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
if (!networkMonitor.checkConnection()) {
offlineAction()
} else {
throw error
}
}
}
private suspend fun runOnlineAction(action: suspend () -> Unit) {
try {
action()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
if (networkMonitor.checkConnection()) {
throw error
}
}
}
}

View File

@@ -4,4 +4,5 @@ import kotlinx.coroutines.flow.Flow
interface NetworkMonitor {
val isOnline: Flow<Boolean>
suspend fun checkConnection(): Boolean
}

View File

@@ -43,17 +43,20 @@ class AppViewModel @Inject constructor(
private val navigationManager: NavigationManager,
private val homeRefreshCoordinator: HomeRefreshCoordinator,
private val settingsRepository: SettingsRepository,
networkMonitor: NetworkMonitor,
private val networkMonitor: NetworkMonitor,
) : ViewModel() {
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
private val _isCheckingConnection = MutableStateFlow(false)
val isCheckingConnection: StateFlow<Boolean> = _isCheckingConnection.asStateFlow()
val isOnline: StateFlow<Boolean> = networkMonitor.isOnline
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false
true
)
val serverUrl: StateFlow<String> = userSessionRepository.serverUrl
@@ -253,18 +256,34 @@ class AppViewModel @Inject constructor(
fun onResumed() {
viewModelScope.launch {
networkMonitor.checkConnection()
homeRefreshCoordinator.onResumed()
}
}
fun onRefresh() {
viewModelScope.launch {
networkMonitor.checkConnection()
homeRefreshCoordinator.onRefresh { isRefreshing ->
_isRefreshing.value = isRefreshing
}
}
}
fun checkConnection() {
viewModelScope.launch {
if (_isCheckingConnection.value) return@launch
_isCheckingConnection.value = true
val isConnected = networkMonitor.checkConnection()
_isCheckingConnection.value = false
if (isConnected) {
homeRefreshCoordinator.onRefresh { isRefreshing ->
_isRefreshing.value = isRefreshing
}
}
}
}
fun openSearch() {
navigationManager.navigate(Route.HomeSearchRoute)
}

View File

@@ -24,6 +24,7 @@ import hu.bbara.purefin.data.offline.cache.toSeries
import hu.bbara.purefin.model.Library
import hu.bbara.purefin.model.LibraryKind
import hu.bbara.purefin.model.Media
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -51,6 +52,7 @@ class InMemoryAppContentRepository @Inject constructor(
private val networkMonitor: NetworkMonitor,
) : HomeRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var cacheLoadJob: Job? = null
private var refreshJob: Job? = null
@OptIn(ExperimentalAtomicApi::class)
@@ -72,10 +74,8 @@ class InMemoryAppContentRepository @Inject constructor(
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = latestLibraryContentState.asStateFlow()
init {
scope.launch {
ensureReady()
}
}
@OptIn(ExperimentalAtomicApi::class)
override fun ensureReady() {
@@ -83,17 +83,23 @@ class InMemoryAppContentRepository @Inject constructor(
return
}
Timber.tag(TAG).d("Initializing home repository")
scope.launch { loadHomeCache() }
scope.launch { refreshHomeData() }
val loadJob = scope.launch { loadHomeCache() }
cacheLoadJob = loadJob
scope.launch {
loadJob.join()
refreshHomeData()
}
}
override suspend fun refreshHomeData() {
cacheLoadJob?.join()
val job = synchronized(this) {
refreshJob?.takeIf { it.isActive } ?: scope.launch {
runCatching {
val snapshot = snapshotHomeContent()
try {
Timber.tag(TAG).d("Refreshing home data")
if (!networkMonitor.isOnline.first()) {
return@runCatching
return@launch
}
loadLibraries()
loadSuggestions()
@@ -102,8 +108,15 @@ class InMemoryAppContentRepository @Inject constructor(
loadLatestLibraryContent()
Timber.tag(TAG).d("Home refresh successful")
persistHomeCache()
}.onFailure { error ->
} catch (error: CancellationException) {
throw error
} catch (error: HomeRefreshFailedException) {
restoreHomeContent(snapshot)
Timber.tag(TAG).w(error.cause, "Home refresh failed; keeping cached content")
} catch (error: Exception) {
restoreHomeContent(snapshot)
Timber.tag(TAG).w(error, "Home refresh failed; keeping cached content")
networkMonitor.checkConnection()
}
}.also { refreshJob = it }
}
@@ -200,8 +213,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadLibraries() {
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load libraries")
return
handleRefreshFailure(error, "Unable to load libraries")
}
val filteredLibraries = librariesItem.filter {
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
@@ -222,8 +234,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadLibrary(library: Library): Library {
val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) }
.getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load library ${library.id}")
return library
handleRefreshFailure(error, "Unable to load library ${library.id}")
}
return when (library.type) {
LibraryKind.MOVIES -> library.copy(
@@ -240,8 +251,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadSuggestions() {
val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() }
.getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load suggestions")
return
handleRefreshFailure(error, "Unable to load suggestions")
}
suggestionsState.value = suggestionsItems.mapNotNull { item ->
when (item.type) {
@@ -261,8 +271,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadContinueWatching() {
val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() }
.getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load continue watching")
return
handleRefreshFailure(error, "Unable to load continue watching")
}
continueWatchingState.value = continueWatchingItems.mapNotNull { item ->
when (item.type) {
@@ -282,8 +291,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadNextUp() {
val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() }
.getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load next up")
return
handleRefreshFailure(error, "Unable to load next up")
}
nextUpState.value = nextUpItems.map { item ->
Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!)
@@ -297,8 +305,7 @@ class InMemoryAppContentRepository @Inject constructor(
private suspend fun loadLatestLibraryContent() {
val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load latest library content")
return
handleRefreshFailure(error, "Unable to load latest library content")
}
val filteredLibraries = librariesItem.filter {
it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS
@@ -306,8 +313,7 @@ class InMemoryAppContentRepository @Inject constructor(
val latestLibraryContents = filteredLibraries.associate { library ->
val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) }
.getOrElse { error ->
Timber.tag(TAG).w(error, "Unable to load latest items for library ${library.id}")
emptyList()
handleRefreshFailure(error, "Unable to load latest items for library ${library.id}")
}
library.id to when (library.collectionType) {
CollectionType.MOVIES -> latestFromLibrary.map {
@@ -341,6 +347,31 @@ class InMemoryAppContentRepository @Inject constructor(
return userSessionRepository.serverUrl.first()
}
private fun snapshotHomeContent(): HomeContentSnapshot = HomeContentSnapshot(
libraries = librariesState.value,
suggestions = suggestionsState.value,
continueWatching = continueWatchingState.value,
nextUp = nextUpState.value,
latestLibraryContent = latestLibraryContentState.value,
)
private fun restoreHomeContent(snapshot: HomeContentSnapshot) {
librariesState.value = snapshot.libraries
suggestionsState.value = snapshot.suggestions
continueWatchingState.value = snapshot.continueWatching
nextUpState.value = snapshot.nextUp
latestLibraryContentState.value = snapshot.latestLibraryContent
}
private suspend fun handleRefreshFailure(error: Throwable, message: String): Nothing {
if (error is CancellationException) {
throw error
}
Timber.tag(TAG).w(error, message)
networkMonitor.checkConnection()
throw HomeRefreshFailedException(error)
}
companion object {
private const val TAG = "InMemoryAppContentRepo"
}
@@ -351,3 +382,13 @@ private data class ReferencedHomeMediaIds(
val seriesIds: Set<UUID>,
val episodeIds: Set<UUID>,
)
private data class HomeContentSnapshot(
val libraries: List<Library>,
val suggestions: List<Media>,
val continueWatching: List<Media>,
val nextUp: List<Media>,
val latestLibraryContent: Map<UUID, List<Media>>,
)
private class HomeRefreshFailedException(cause: Throwable) : RuntimeException(cause)

View File

@@ -7,28 +7,48 @@ import android.net.NetworkCapabilities
import android.net.NetworkRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import hu.bbara.purefin.core.data.NetworkMonitor
import kotlinx.coroutines.channels.awaitClose
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConnectivityNetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context,
private val jellyfinApiClient: JellyfinApiClient,
) : NetworkMonitor {
override val isOnline: Flow<Boolean> = callbackFlow {
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
private val isAndroidConnected = MutableStateFlow(connectivityManager.isCurrentlyConnected())
private val isServerReachable = MutableStateFlow(true)
override val isOnline: Flow<Boolean> = combine(
isAndroidConnected,
isServerReachable,
) { androidConnected, serverReachable ->
androidConnected && serverReachable
}.distinctUntilChanged()
init {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(true)
isAndroidConnected.value = true
scope.launch { updateServerReachability() }
}
override fun onLost(network: Network) {
trySend(connectivityManager.isCurrentlyConnected())
isAndroidConnected.value = connectivityManager.isCurrentlyConnected()
}
}
@@ -36,14 +56,42 @@ class ConnectivityNetworkMonitor @Inject constructor(
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
trySend(connectivityManager.isCurrentlyConnected())
connectivityManager.registerNetworkCallback(request, callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
}
override suspend fun checkConnection(): Boolean {
val androidConnected = connectivityManager.isCurrentlyConnected()
isAndroidConnected.value = androidConnected
if (!androidConnected) {
isServerReachable.value = false
return false
}
return updateServerReachability()
}
private suspend fun updateServerReachability(): Boolean {
val reachable = try {
withTimeoutOrNull(SERVER_CHECK_TIMEOUT_MS) {
jellyfinApiClient.probeServer()
} ?: false
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Timber.tag(TAG).w(error, "Jellyfin server reachability check failed")
false
}
isServerReachable.value = reachable
return reachable
}
private fun ConnectivityManager.isCurrentlyConnected(): Boolean {
val network = activeNetwork ?: return false
val caps = getNetworkCapabilities(network) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
private companion object {
private const val TAG = "ConnectivityNetworkMonitor"
private const val SERVER_CHECK_TIMEOUT_MS = 5_000L
}
}

View File

@@ -67,18 +67,31 @@ class DefaultPlayableMediaRepository @Inject constructor(
return getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
}
return runCatching {
val onlinePlayableMedia = runCatching {
getOnlinePlayableMedia(mediaId, downloadedMediaItem)
?: getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
}.getOrElse { error ->
if (error is CancellationException) throw error
networkMonitor.checkConnection()
Timber.tag(TAG).w(error, "Unable to load online metadata for downloaded media $mediaId")
getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
null
}
if (onlinePlayableMedia != null) return onlinePlayableMedia
networkMonitor.checkConnection()
return getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem)
}
private suspend fun getStreamingPlayableMedia(mediaId: UUID): PlayableMedia? {
return getOnlinePlayableMedia(mediaId, downloadedMediaItem = null)
if (!networkMonitor.isOnline.first()) return null
return runCatching {
getOnlinePlayableMedia(mediaId, downloadedMediaItem = null)
}.getOrElse { error ->
if (error is CancellationException) throw error
networkMonitor.checkConnection()
Timber.tag(TAG).w(error, "Unable to load streaming media $mediaId")
null
}
}
private suspend fun getOnlinePlayableMedia(

View File

@@ -26,6 +26,7 @@ import org.jellyfin.sdk.api.client.extensions.mediaSegmentsApi
import org.jellyfin.sdk.api.client.extensions.playStateApi
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
@@ -227,6 +228,16 @@ class JellyfinApiClient @Inject constructor(
}
}
suspend fun probeServer(): Boolean = withContext(Dispatchers.IO) {
logRequest("probeServer") {
if (!ensureConfigured()) {
return@logRequest false
}
api.systemApi.getPingSystem()
true
}
}
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto> = withContext(Dispatchers.IO) {
logRequest("getLibraryContent") {
if (!ensureConfigured()) {