mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
feat(connectivity): add offline resilience with server reachability checks
This commit is contained in:
@@ -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,9 +74,7 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = latestLibraryContentState.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
ensureReady()
|
||||
}
|
||||
ensureReady()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user