From b91651cabe8348441a6d50c4b893112373d5f687 Mon Sep 17 00:00:00 2001 From: Taneesha Reyyi Date: Wed, 3 Jun 2026 21:44:56 +0530 Subject: [PATCH] feat: add IPTV stream health monitoring system --- app/build.gradle.kts | 5 + .../tv/data/local/IptvHealthDatabase.kt | 13 ++ .../tv/data/local/IptvStreamHealthDao.kt | 25 +++ .../tv/data/local/IptvStreamHealthEntity.kt | 14 ++ .../com/arflix/tv/data/model/IptvModels.kt | 63 ++++++- .../tv/data/repository/IptvRepository.kt | 174 +++++++++++++++++- .../main/kotlin/com/arflix/tv/di/AppModule.kt | 17 ++ .../tv/ui/screens/settings/SettingsScreen.kt | 26 ++- .../ui/screens/settings/SettingsViewModel.kt | 52 ++++++ .../com/arflix/tv/ui/screens/tv/TvScreen.kt | 9 + 10 files changed, 393 insertions(+), 5 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/data/local/IptvHealthDatabase.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/local/IptvStreamHealthDao.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/local/IptvStreamHealthEntity.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2df2653a..103a9d39 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -268,6 +268,11 @@ dependencies { // DataStore for preferences implementation("androidx.datastore:datastore-preferences:1.0.0") + // Room local persistence for IPTV health state + implementation("androidx.room:room-runtime:2.5.2") + implementation("androidx.room:room-ktx:2.5.2") + ksp("androidx.room:room-compiler:2.5.2") + // Google Cast SDK — mobile-only at runtime (guarded by DeviceType check), harmless on TV implementation("com.google.android.gms:play-services-cast-framework:21.4.0") implementation("androidx.mediarouter:mediarouter:1.7.0") diff --git a/app/src/main/kotlin/com/arflix/tv/data/local/IptvHealthDatabase.kt b/app/src/main/kotlin/com/arflix/tv/data/local/IptvHealthDatabase.kt new file mode 100644 index 00000000..6816902b --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/local/IptvHealthDatabase.kt @@ -0,0 +1,13 @@ +package com.arflix.tv.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [IptvStreamHealthEntity::class], + version = 1, + exportSchema = false +) +abstract class IptvHealthDatabase : RoomDatabase() { + abstract fun streamHealthDao(): IptvStreamHealthDao +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/local/IptvStreamHealthDao.kt b/app/src/main/kotlin/com/arflix/tv/data/local/IptvStreamHealthDao.kt new file mode 100644 index 00000000..28599689 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/local/IptvStreamHealthDao.kt @@ -0,0 +1,25 @@ +package com.arflix.tv.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface IptvStreamHealthDao { + @Query("SELECT * FROM iptv_stream_health") + fun observeAll(): Flow> + + @Query("SELECT * FROM iptv_stream_health WHERE channelId IN (:channelIds)") + suspend fun loadByChannelIds(channelIds: List): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entity: IptvStreamHealthEntity) + + @Query("DELETE FROM iptv_stream_health WHERE channelId NOT IN (:channelIds)") + suspend fun deleteStaleHealthEntries(channelIds: List) + + @Query("DELETE FROM iptv_stream_health") + suspend fun clearAll() +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/local/IptvStreamHealthEntity.kt b/app/src/main/kotlin/com/arflix/tv/data/local/IptvStreamHealthEntity.kt new file mode 100644 index 00000000..7eabbcbb --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/local/IptvStreamHealthEntity.kt @@ -0,0 +1,14 @@ +package com.arflix.tv.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "iptv_stream_health") +data class IptvStreamHealthEntity( + @PrimaryKey val channelId: String, + val httpStatusCode: Int? = null, + val latencyMs: Long? = null, + val consecutiveFailureCount: Int = 0, + val lastSuccessfulAtMs: Long? = null, + val lastCheckedAtMs: Long = 0L +) diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt b/app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt index 786fe650..a56996d0 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/model/IptvModels.kt @@ -23,11 +23,47 @@ data class IptvChannel( val language: String? = null, val country: String? = null, val qualityLabel: String? = null, - val variantKey: String? = null + val variantKey: String? = null, + val health: IptvChannelHealth = IptvChannelHealth() ) /** - * Compact now/next program slice for a channel. + * Status of the last known health check for an IPTV stream. + */ +enum class IptvChannelHealthStatus { + UNKNOWN, + HEALTHY, + DEGRADED, + OFFLINE +} + +data class IptvChannelHealth( + val httpStatusCode: Int? = null, + val latencyMs: Long? = null, + val consecutiveFailureCount: Int = 0, + val lastSuccessfulAtMs: Long? = null, + val lastCheckedAtMs: Long = 0L +) { + val status: IptvChannelHealthStatus + get() = when { + lastCheckedAtMs == 0L -> IptvChannelHealthStatus.UNKNOWN + consecutiveFailureCount >= 3 -> IptvChannelHealthStatus.OFFLINE + consecutiveFailureCount > 0 -> IptvChannelHealthStatus.DEGRADED + httpStatusCode != null && httpStatusCode in 200..299 -> IptvChannelHealthStatus.HEALTHY + else -> IptvChannelHealthStatus.UNKNOWN + } + + val summaryText: String + get() = when (status) { + IptvChannelHealthStatus.HEALTHY -> "Healthy" + IptvChannelHealthStatus.DEGRADED -> "Degraded" + IptvChannelHealthStatus.OFFLINE -> "Offline" + IptvChannelHealthStatus.UNKNOWN -> "Unknown" + } +} + +/** + * Loaded IPTV snapshot used by UI. */ data class IptvNowNext( val now: IptvProgram? = null, @@ -66,6 +102,29 @@ data class IptvSnapshot( val loadedAt: Instant = Instant.now() ) +data class IptvHealthSummary( + val total: Int = 0, + val healthy: Int = 0, + val degraded: Int = 0, + val offline: Int = 0, + val unknown: Int = 0, + val lastCheckedAtMs: Long = 0L +) { + val summaryText: String + get() = when { + total == 0 -> "No health checks yet" + else -> listOfNotNull( + healthy.takeIf { it > 0 }?.let { "$it healthy" }, + degraded.takeIf { it > 0 }?.let { "$it degraded" }, + offline.takeIf { it > 0 }?.let { "$it offline" }, + unknown.takeIf { it > 0 }?.let { "$it unknown" } + ).joinToString(" • ") + } + + val lastCheckedText: String + get() = if (lastCheckedAtMs == 0L) "" else "Last checked ${java.time.Duration.between(java.time.Instant.ofEpochMilli(lastCheckedAtMs), java.time.Instant.now()).abs().toMinutes()}m ago" +} + /** * Lightweight helper to handle playlistId|groupName composite keys without * unnecessary string allocations in UI loops. diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index 06ce65f4..a9eceff6 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -6,7 +6,12 @@ import android.security.keystore.KeyProperties import android.util.Base64 import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit +import com.arflix.tv.data.local.IptvStreamHealthDao +import com.arflix.tv.data.local.IptvStreamHealthEntity import com.arflix.tv.data.model.IptvChannel +import com.arflix.tv.data.model.IptvChannelHealth +import com.arflix.tv.data.model.IptvChannelHealthStatus +import com.arflix.tv.data.model.IptvHealthSummary import com.arflix.tv.data.model.IptvNowNext import com.arflix.tv.data.model.IptvProgram import com.arflix.tv.data.model.IptvSnapshot @@ -158,7 +163,8 @@ class IptvRepository @Inject constructor( @ApplicationContext private val context: Context, private val okHttpClient: OkHttpClient, private val profileManager: ProfileManager, - private val invalidationBus: CloudSyncInvalidationBus + private val invalidationBus: CloudSyncInvalidationBus, + private val streamHealthDao: IptvStreamHealthDao ) { private val gson = Gson() private val loadMutex = Mutex() @@ -325,6 +331,172 @@ class IptvRepository @Inject constructor( .build() } + private val iptvHealthHttpClient: OkHttpClient by lazy { + okHttpClient.newBuilder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .callTimeout(25, TimeUnit.SECONDS) + .build() + } + + private val maxHealthCheckChannels = 60 + + private suspend fun mergeHealthIntoChannels(channels: List): List { + if (channels.isEmpty()) return channels + val healthMap = streamHealthDao.loadByChannelIds(channels.map { it.id }).associateBy { it.channelId } + return channels.map { channel -> + val health = healthMap[channel.id]?.let { entity -> + IptvChannelHealth( + httpStatusCode = entity.httpStatusCode, + latencyMs = entity.latencyMs, + consecutiveFailureCount = entity.consecutiveFailureCount, + lastSuccessfulAtMs = entity.lastSuccessfulAtMs, + lastCheckedAtMs = entity.lastCheckedAtMs + ) + } ?: IptvChannelHealth() + channel.copy(health = health) + } + } + + fun observeIptvHealthSummary(): Flow { + return streamHealthDao.observeAll().map { entries -> + val mapped = entries.map { entity -> + val health = IptvChannelHealth( + httpStatusCode = entity.httpStatusCode, + latencyMs = entity.latencyMs, + consecutiveFailureCount = entity.consecutiveFailureCount, + lastSuccessfulAtMs = entity.lastSuccessfulAtMs, + lastCheckedAtMs = entity.lastCheckedAtMs + ) + health.status + } + val healthy = mapped.count { it == IptvChannelHealthStatus.HEALTHY } + val degraded = mapped.count { it == IptvChannelHealthStatus.DEGRADED } + val offline = mapped.count { it == IptvChannelHealthStatus.OFFLINE } + val unknown = mapped.count { it == IptvChannelHealthStatus.UNKNOWN } + val lastCheckedAtMs = entries.maxOfOrNull { it.lastCheckedAtMs } ?: 0L + IptvHealthSummary( + total = entries.size, + healthy = healthy, + degraded = degraded, + offline = offline, + unknown = unknown, + lastCheckedAtMs = lastCheckedAtMs + ) + } + } + + suspend fun recordIptvPlaybackFailure(channelId: String, errorMessage: String?) { + val existing = streamHealthDao.loadByChannelIds(listOf(channelId)).firstOrNull() + val updated = IptvStreamHealthEntity( + channelId = channelId, + httpStatusCode = null, + latencyMs = null, + consecutiveFailureCount = (existing?.consecutiveFailureCount ?: 0) + 1, + lastSuccessfulAtMs = existing?.lastSuccessfulAtMs, + lastCheckedAtMs = System.currentTimeMillis() + ) + streamHealthDao.upsert(updated) + } + + suspend fun runIptvHealthChecks(onProgress: (IptvLoadProgress) -> Unit = {}) { + val config = observeConfig().first() + val playlists = activePlaylists(config) + val channels = mutableListOf() + if (config.m3uUrl.isBlank() && config.stalkerPortalUrl.isNotBlank()) { + onProgress(IptvLoadProgress("Connecting to Stalker portal...", null)) + val stalker = com.arflix.tv.data.api.StalkerApi(config.stalkerPortalUrl, config.stalkerMacAddress) + if (stalker.handshake()) { + onProgress(IptvLoadProgress("Loading channels from Stalker portal...", null)) + channels += stalker.getChannels() + cachedStalkerApi = stalker + } + } else { + playlists.forEachIndexed { index, playlist -> + val label = "Loading playlist ${index + 1}/${playlists.size}" + onProgress(IptvLoadProgress(label, null)) + val playlistChannels = runCatching { + fetchAndParseM3uWithRetries(playlist.m3uUrl, playlist.id, playlist.epgUrls) + }.getOrElse { + emptyList() + } + channels += playlistChannels + } + } + + val uniqueChannels = channels.distinctBy { it.id } + val toCheck = uniqueChannels.take(maxHealthCheckChannels) + val semaphore = Semaphore(4) + coroutineScope { + toCheck.mapIndexed { index, channel -> + async { + semaphore.withPermit { + onProgress(IptvLoadProgress("Checking ${index + 1}/${toCheck.size} ${channel.name}", null)) + val streamUrl = resolveHealthCheckUrl(channel) + val now = System.currentTimeMillis() + val healthResult = runCatching { + val request = Request.Builder().url(streamUrl).head().build() + val started = System.currentTimeMillis() + iptvHealthHttpClient.newCall(request).execute().use { response -> + val latency = System.currentTimeMillis() - started + IptvStreamHealthEntity( + channelId = channel.id, + httpStatusCode = response.code, + latencyMs = latency, + consecutiveFailureCount = 0, + lastSuccessfulAtMs = now, + lastCheckedAtMs = now + ) + } + }.getOrElse { error -> + val existing = streamHealthDao.loadByChannelIds(listOf(channel.id)).firstOrNull() + IptvStreamHealthEntity( + channelId = channel.id, + httpStatusCode = null, + latencyMs = null, + consecutiveFailureCount = (existing?.consecutiveFailureCount ?: 0) + 1, + lastSuccessfulAtMs = existing?.lastSuccessfulAtMs, + lastCheckedAtMs = now + ) + } + streamHealthDao.upsert(healthResult) + } + } + }.awaitAll() + } + if (uniqueChannels.isNotEmpty()) { + streamHealthDao.deleteStaleHealthEntries(uniqueChannels.map { it.id }) + } else { + streamHealthDao.clearAll() + } + } + + private fun resolveHealthCheckUrl(channel: IptvChannel): String { + val stream = channel.streamUrl + if (stream.startsWith("ffmpeg") || (stream.startsWith("/") && !stream.startsWith("//"))) { + val stalker = cachedStalkerApi + if (stalker != null) { + val resolved = stalker.resolveStreamUrl(stream) + if (!resolved.isNullOrBlank()) { + return resolved + } + } + } + return stream + } + + private fun findOrCreateStalkerApi(config: IptvConfig): com.arflix.tv.data.api.StalkerApi? { + val existing = cachedStalkerApi + if (existing != null) return existing + return if (config.stalkerPortalUrl.isNotBlank() && config.stalkerMacAddress.isNotBlank()) { + com.arflix.tv.data.api.StalkerApi(config.stalkerPortalUrl, config.stalkerMacAddress) + .also { cachedStalkerApi = it } + } else { + null + } + } + private data class IptvCachePayload( val channels: List = emptyList(), val nowNext: Map = emptyMap(), diff --git a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt index 7dfc4363..e9f0fda4 100644 --- a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt +++ b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt @@ -4,10 +4,13 @@ import android.content.Context import com.arflix.tv.data.api.AniSkipApi import com.arflix.tv.data.api.ArmApi import com.arflix.tv.data.api.IntroDbApi +import androidx.room.Room import com.arflix.tv.data.api.StreamApi import com.arflix.tv.data.api.SupabaseApi import com.arflix.tv.data.api.TmdbApi import com.arflix.tv.data.api.TraktApi +import com.arflix.tv.data.local.IptvHealthDatabase +import com.arflix.tv.data.local.IptvStreamHealthDao import com.arflix.tv.network.OkHttpProvider import com.arflix.tv.util.Constants import dagger.Module @@ -31,6 +34,20 @@ object AppModule { return OkHttpProvider.client } + @Provides + @Singleton + fun provideIptvHealthDatabase(@ApplicationContext context: Context): IptvHealthDatabase { + return Room.databaseBuilder(context, IptvHealthDatabase::class.java, "iptv_health.db") + .fallbackToDestructiveMigration() + .build() + } + + @Provides + @Singleton + fun provideIptvStreamHealthDao(database: IptvHealthDatabase): IptvStreamHealthDao { + return database.streamHealthDao() + } + @Provides @Singleton fun provideTmdbApi(okHttpClient: OkHttpClient): TmdbApi { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index d941efa7..a5f882c1 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -383,7 +383,7 @@ fun SettingsScreen( "iptv" -> if (showIptvCategoriesSettings) { uiState.iptvAvailableGroups.size // Reset row + category rows } else { - 2 + uiState.iptvPlaylists.size // Add + rows + refresh + clear + 3 + uiState.iptvPlaylists.size // Add + rows + refresh + health + clear } "home_server" -> uiState.homeServerConnections.size + 3 "catalogs" -> uiState.catalogs.size // Add + rows @@ -921,6 +921,9 @@ fun SettingsScreen( viewModel.refreshIptv(force = true) } contentFocusIndex == uiState.iptvPlaylists.size + 2 -> { + viewModel.refreshIptvHealth() + } + contentFocusIndex == uiState.iptvPlaylists.size + 3 -> { viewModel.clearIptvConfig() } } @@ -1369,7 +1372,11 @@ fun SettingsScreen( } }, onRefresh = { viewModel.refreshIptv() }, + onRefreshHealth = { viewModel.refreshIptvHealth() }, onDelete = { viewModel.clearIptvConfig() }, + healthSummary = uiState.iptvHealthSummary, + isHealthChecking = uiState.isIptvHealthChecking, + healthStatusMessage = uiState.iptvHealthStatusMessage, onManageCategories = openIptvCategories ) "TV" -> IptvSettings( @@ -1413,7 +1420,11 @@ fun SettingsScreen( } }, onRefresh = { viewModel.refreshIptv() }, + onRefreshHealth = { viewModel.refreshIptvHealth() }, onDelete = { viewModel.clearIptvConfig() }, + healthSummary = uiState.iptvHealthSummary, + isHealthChecking = uiState.isIptvHealthChecking, + healthStatusMessage = uiState.iptvHealthStatusMessage, onManageCategories = openIptvCategories ) "home_server" -> HomeServerSettings( @@ -3760,7 +3771,11 @@ private fun MobileSettingsSubPage( } }, onRefresh = { viewModel.refreshIptv() }, + onRefreshHealth = { viewModel.refreshIptvHealth() }, onDelete = { viewModel.clearIptvConfig() }, + healthSummary = uiState.iptvHealthSummary, + isHealthChecking = uiState.isIptvHealthChecking, + healthStatusMessage = uiState.iptvHealthStatusMessage, onManageCategories = { playlistId -> viewModel.setIptvSelectedPlaylistId(playlistId) onNavigate("IPTV_CATEGORIES") @@ -5746,7 +5761,11 @@ private fun IptvSettings( onMovePlaylistDown: (Int) -> Unit, onDeletePlaylist: (Int) -> Unit, onRefresh: () -> Unit, + onRefreshHealth: () -> Unit, onDelete: () -> Unit, + healthSummary: com.arflix.tv.data.model.IptvHealthSummary, + isHealthChecking: Boolean, + healthStatusMessage: String?, onManageCategories: (String) -> Unit = {} ) { val isMobile = LocalDeviceType.current.isTouchDevice() @@ -5831,6 +5850,7 @@ private fun IptvSettings( MobileSettingsCategory(title = "ACTIONS") { val refreshSubtitle = when { isLoading -> "Refreshing channels and EPG..."; error != null -> error; playlists.none { it.epgUrl.isNotBlank() || it.epgUrls.orEmpty().isNotEmpty() } -> "Reload playlists now"; else -> "Reload playlist and EPG now" } MobileSettingsRow(icon = Icons.Default.Link, title = stringResource(R.string.refresh_iptv), subtitle = refreshSubtitle, value = if (isLoading) "Loading" else "", isFocused = false, onClick = onRefresh) + MobileSettingsRow(icon = Icons.Default.Check, title = "Run IPTV health check", subtitle = healthStatusMessage ?: healthSummary.summaryText, value = if (isHealthChecking) "CHECKING" else "RUN", isFocused = false, onClick = onRefreshHealth) MobileSettingsRow(icon = Icons.Default.Delete, title = stringResource(R.string.delete_iptv), subtitle = if (playlists.isEmpty()) "No playlists configured" else "Remove playlists, EPG and favorites", value = "", isFocused = false, showDivider = false, onClick = onDelete) } if (isLoading && !progressText.isNullOrBlank()) { @@ -5898,7 +5918,9 @@ private fun IptvSettings( val refreshSubtitle = when { isLoading -> "Refreshing channels and EPG..."; error != null -> error; playlists.none { it.epgUrl.isNotBlank() || it.epgUrls.orEmpty().isNotEmpty() } -> "Reload playlists now"; else -> "Reload playlist and EPG now" } SettingsRow(icon = Icons.Default.Link, title = stringResource(R.string.refresh_iptv), subtitle = refreshSubtitle, value = if (isLoading) "LOADING" else "REFRESH", isFocused = focusedIndex == playlists.size + 1, onClick = onRefresh, modifier = Modifier.settingsFocusSlot(playlists.size + 1)) Spacer(modifier = Modifier.height(16.dp)) - SettingsRow(icon = Icons.Default.Delete, title = stringResource(R.string.delete_iptv), subtitle = if (playlists.isEmpty()) "No playlists configured" else "Remove playlists, EPG and favorites", value = if (playlists.isEmpty()) "EMPTY" else "DELETE", isFocused = focusedIndex == playlists.size + 2, onClick = onDelete, modifier = Modifier.settingsFocusSlot(playlists.size + 2)) + SettingsRow(icon = Icons.Default.Check, title = "Run IPTV health check", subtitle = healthStatusMessage ?: healthSummary.summaryText, value = if (isHealthChecking) "CHECKING" else "RUN", isFocused = focusedIndex == playlists.size + 2, onClick = onRefreshHealth, modifier = Modifier.settingsFocusSlot(playlists.size + 2)) + Spacer(modifier = Modifier.height(16.dp)) + SettingsRow(icon = Icons.Default.Delete, title = stringResource(R.string.delete_iptv), subtitle = if (playlists.isEmpty()) "No playlists configured" else "Remove playlists, EPG and favorites", value = if (playlists.isEmpty()) "EMPTY" else "DELETE", isFocused = focusedIndex == playlists.size + 3, onClick = onDelete, modifier = Modifier.settingsFocusSlot(playlists.size + 3)) if (isLoading && !progressText.isNullOrBlank()) { Spacer(modifier = Modifier.height(12.dp)) Text("$progressText (${progressPercent.coerceIn(0, 100)}%)", style = ArflixTypography.caption, color = TextSecondary) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index a40662d7..1558f327 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -29,6 +29,7 @@ import com.arflix.tv.data.repository.HomeServerConnection import com.arflix.tv.data.repository.HomeServerRepository import com.arflix.tv.data.repository.PlexPinAuthSession import com.arflix.tv.data.repository.IptvConfig +import com.arflix.tv.data.repository.IptvLoadProgress import com.arflix.tv.data.repository.IptvRepository import com.arflix.tv.data.repository.IptvPlaylistEntry import com.arflix.tv.data.repository.LauncherContinueWatchingRepository @@ -148,6 +149,9 @@ data class SettingsUiState( val iptvError: String? = null, val iptvStatusMessage: String? = null, val iptvStatusType: ToastType = ToastType.INFO, + val iptvHealthSummary: com.arflix.tv.data.model.IptvHealthSummary = com.arflix.tv.data.model.IptvHealthSummary(), + val isIptvHealthChecking: Boolean = false, + val iptvHealthStatusMessage: String? = null, val iptvProgressText: String? = null, val iptvProgressPercent: Int = 0, val iptvSelectedPlaylistId: String? = null, @@ -360,6 +364,7 @@ class SettingsViewModel @Inject constructor( observeSyncState() observeAuthState() observeIptvConfig() + observeIptvHealthSummary() observeIptvGroupPrefs() initializeCatalogs() observeCatalogs() @@ -1639,6 +1644,16 @@ class SettingsViewModel @Inject constructor( } } + private fun observeIptvHealthSummary() { + viewModelScope.launch { + iptvRepository.observeIptvHealthSummary().collect { summary -> + _uiState.value = _uiState.value.copy( + iptvHealthSummary = summary + ) + } + } + } + private fun observeCatalogs() { viewModelScope.launch { catalogRepository.observeCatalogs().collect { catalogs -> @@ -1992,6 +2007,43 @@ class SettingsViewModel @Inject constructor( } } + fun refreshIptvHealth() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isIptvHealthChecking = true, + iptvHealthStatusMessage = "Starting IPTV health check..." + ) + runCatching { + iptvRepository.runIptvHealthChecks { progress -> + _uiState.value = _uiState.value.copy( + iptvHealthStatusMessage = progress.message + ) + } + }.onSuccess { + _uiState.value = _uiState.value.copy( + isIptvHealthChecking = false, + iptvHealthStatusMessage = "IPTV health check complete", + toastMessage = "IPTV health scan complete", + toastType = ToastType.SUCCESS + ) + }.onFailure { error -> + if (error is CancellationException) { + _uiState.value = _uiState.value.copy( + isIptvHealthChecking = false, + iptvHealthStatusMessage = "IPTV health check cancelled" + ) + return@onFailure + } + _uiState.value = _uiState.value.copy( + isIptvHealthChecking = false, + iptvHealthStatusMessage = error.message ?: "IPTV health check failed", + toastMessage = error.message ?: "IPTV health check failed", + toastType = ToastType.ERROR + ) + } + } + } + fun clearIptvConfig() { viewModelScope.launch { iptvLoadJob?.cancel() diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt index e796eb69..f17e81b6 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt @@ -137,6 +137,8 @@ import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import androidx.compose.runtime.rememberCoroutineScope import kotlin.math.abs @@ -574,6 +576,7 @@ fun TvScreen( // Keep an always-current reference to the playing channel's stream URL // so the error listener never captures a stale closure. + val playbackHealthScope = rememberCoroutineScope() val currentStreamUrl by rememberUpdatedState(playingChannel?.streamUrl) DisposableEffect(Unit) { @@ -702,6 +705,12 @@ fun TvScreen( if (playerRetryCount > 3) { // Stop retrying after 3 attempts System.err.println("[IPTV] Playback failed after 3 retries: ${error.message} URL=$stream") + val channelId = playingChannelId + if (!channelId.isNullOrBlank()) { + playbackHealthScope.launch { + viewModel.iptvRepository.recordIptvPlaybackFailure(channelId, error.message) + } + } return } player.clearMediaItems()