From 886283282beaddbc04b4a8b1cbf31e9396b46ef5 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:31:44 -0500 Subject: [PATCH] Live TV: heartbeat the session while the player is open The server reclaims a live session's tuner when nothing fetches the stream or heartbeats it for stale_after_seconds (90), which a long pause hits. The player now POSTs /api/v2/livetv/sessions/{id}/heartbeat every third of that window until stop, a new channel, or a 404 (session gone). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../prairie/network/api/LiveTvApi.kt | 5 +++ .../prairie/repository/LiveTvRepository.kt | 3 ++ .../prairie/viewmodel/LiveTvViewModels.kt | 26 +++++++++++++ .../model/feature/LiveTvFeatureStoreTest.kt | 3 ++ .../prairie/network/api/LiveTvApiTest.kt | 10 +++++ .../repository/LiveTvRepositoryTest.kt | 7 ++++ .../prairie/viewmodel/LiveTvViewModelTest.kt | 39 ++++++++++++++++++- 7 files changed, 92 insertions(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/LiveTvApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/LiveTvApi.kt index 749aae4066..773a01858f 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/LiveTvApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/LiveTvApi.kt @@ -44,6 +44,8 @@ interface LiveTvApi { suspend fun startSession(channelId: String): ApiResult + suspend fun heartbeatSession(sessionId: String): ApiResult + suspend fun releaseSession(sessionId: String): ApiResult suspend fun recordings(status: String? = null): ApiResult @@ -90,6 +92,9 @@ class DefaultLiveTvApi( override suspend fun startSession(channelId: String): ApiResult = call(HttpMethod.Post, "/channels/${channelId.encodeURLPathPart()}/sessions") + override suspend fun heartbeatSession(sessionId: String): ApiResult = + call(HttpMethod.Post, "/sessions/${sessionId.encodeURLPathPart()}/heartbeat") + override suspend fun releaseSession(sessionId: String): ApiResult = call(HttpMethod.Delete, "/sessions/${sessionId.encodeURLPathPart()}") diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/LiveTvRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/LiveTvRepository.kt index bb81da4c38..a592d14090 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/LiveTvRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/LiveTvRepository.kt @@ -39,6 +39,9 @@ class LiveTvRepository(private val api: LiveTvApi) { suspend fun startSession(channelId: String): ApiResult = api.startSession(channelId) + suspend fun heartbeatSession(sessionId: String): ApiResult = + api.heartbeatSession(sessionId) + suspend fun releaseSession(sessionId: String): ApiResult = api.releaseSession(sessionId) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModels.kt index 457e04f0b0..25eb1bdf9f 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModels.kt @@ -2,6 +2,8 @@ package org.prairieserver.prairie.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -280,6 +282,7 @@ class LiveTvPlayerViewModel( private val _uiState = MutableStateFlow(LiveTvPlayerUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var heartbeatJob: Job? = null fun start(channelId: String, channelName: String = "") { if (channelId.isBlank()) { @@ -288,6 +291,7 @@ class LiveTvPlayerViewModel( } return } + heartbeatJob?.cancel() viewModelScope.launch { val previousSessionId = _uiState.value.session?.sessionId if (!previousSessionId.isNullOrBlank()) { @@ -318,6 +322,7 @@ class LiveTvPlayerViewModel( _uiState.update { it.copy(isStarting = false, session = result.data, error = null) } + startHeartbeat(result.data) } } is ApiResult.Error, is ApiResult.NetworkError -> { @@ -333,7 +338,24 @@ class LiveTvPlayerViewModel( } } + // The server reclaims a session's tuner when nothing fetches the stream or + // heartbeats it for stale_after_seconds, which a long pause would hit. + private fun startHeartbeat(session: LiveTvSessionStartResponse) { + val staleAfter = session.staleAfterSeconds.takeIf { it > 0 } ?: DEFAULT_STALE_AFTER_SECONDS + val intervalMillis = staleAfter * 1000L / 3 + heartbeatJob = viewModelScope.launch { + while (true) { + delay(intervalMillis) + val result = repository.heartbeatSession(session.sessionId) + // 404: the session is no longer active; nothing left to keep alive. + if (result is ApiResult.Error && result.code == 404) break + } + } + } + fun stop() { + heartbeatJob?.cancel() + heartbeatJob = null val sessionId = _uiState.value.session?.sessionId _uiState.update { it.copy(session = null, isStarting = false) } if (sessionId.isNullOrBlank()) return @@ -343,6 +365,7 @@ class LiveTvPlayerViewModel( } override fun onCleared() { + heartbeatJob?.cancel() val sessionId = _uiState.value.session?.sessionId if (!sessionId.isNullOrBlank()) { // Best-effort: viewModelScope is cancelled in onCleared, so fire a @@ -353,3 +376,6 @@ class LiveTvPlayerViewModel( super.onCleared() } } + +// The server default when a session start omits stale_after_seconds. +private const val DEFAULT_STALE_AFTER_SECONDS = 90 diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/LiveTvFeatureStoreTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/LiveTvFeatureStoreTest.kt index 4b5687b63f..6cad83100a 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/LiveTvFeatureStoreTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/feature/LiveTvFeatureStoreTest.kt @@ -131,6 +131,9 @@ private class SequencingLiveTvApi : org.prairieserver.prairie.network.api.LiveTv override suspend fun startSession(channelId: String) = ApiResult.NetworkError(IllegalStateException("unused")) + override suspend fun heartbeatSession(sessionId: String) = + ApiResult.NetworkError(IllegalStateException("unused")) + override suspend fun releaseSession(sessionId: String) = ApiResult.NetworkError(IllegalStateException("unused")) diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/LiveTvApiTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/LiveTvApiTest.kt index fe7be38523..73ee46872e 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/LiveTvApiTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/LiveTvApiTest.kt @@ -120,6 +120,16 @@ class LiveTvApiTest { assertEquals("/api/v1/livetv/sessions/s2/stream", session.playableUrl) } + @Test + fun heartbeatSessionPostsHeartbeat() = runTest { + val captured = Captured() + val result = api(captured, body = "", status = HttpStatusCode.NoContent).heartbeatSession("s1") + + assertEquals(HttpMethod.Post, captured.method) + assertEquals("/api/v2/livetv/sessions/s1/heartbeat", captured.path) + assertIs>(result) + } + @Test fun releaseSessionDeletesSession() = runTest { val captured = Captured() diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/LiveTvRepositoryTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/LiveTvRepositoryTest.kt index 9b986fa2a3..5dc4572a0a 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/LiveTvRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/LiveTvRepositoryTest.kt @@ -109,6 +109,8 @@ internal class FakeLiveTvApi( ) : LiveTvApi { var startCalls = 0 val releaseCalls = mutableListOf() + val heartbeatCalls = mutableListOf() + var heartbeatResult: ApiResult = ApiResult.Success(Unit) override suspend fun channels(tunerId: String?): ApiResult = channelsResult @@ -125,6 +127,11 @@ internal class FakeLiveTvApi( return startResult } + override suspend fun heartbeatSession(sessionId: String): ApiResult { + heartbeatCalls += sessionId + return heartbeatResult + } + override suspend fun releaseSession(sessionId: String): ApiResult { releaseCalls += sessionId return releaseResult diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModelTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModelTest.kt index d9e28347b1..8266b64d96 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModelTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModelTest.kt @@ -5,8 +5,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.prairieserver.prairie.model.livetv.LiveTvChannel @@ -118,7 +120,7 @@ class LiveTvViewModelTest { val viewModel = track(LiveTvPlayerViewModel(LiveTvRepository(api))) viewModel.start("ch-1", "KRON") - advanceUntilIdle() + runCurrent() assertEquals("s1", viewModel.uiState.value.session?.sessionId) assertEquals("/api/v1/livetv/live-hls/s1/index.m3u8", viewModel.uiState.value.session?.playableUrl) @@ -132,6 +134,41 @@ class LiveTvViewModelTest { assertEquals(listOf("s1"), api.releaseCalls) } + @Test + fun playerHeartbeatsUntilStopped() = runTest { + val api = FakeLiveTvApi( + startResult = ApiResult.Success( + LiveTvSessionStartResponse(sessionId = "s1", streamUrl = "/stream", staleAfterSeconds = 90), + ), + releaseResult = ApiResult.Success(Unit), + ) + val viewModel = track(LiveTvPlayerViewModel(LiveTvRepository(api))) + viewModel.start("ch-1") + runCurrent() + + advanceTimeBy(30_001) + assertEquals(listOf("s1"), api.heartbeatCalls) + advanceTimeBy(30_000) + assertEquals(listOf("s1", "s1"), api.heartbeatCalls) + + viewModel.stop() + advanceUntilIdle() + assertEquals(2, api.heartbeatCalls.size) + } + + @Test + fun playerStopsHeartbeatWhenSessionIsGone() = runTest { + val api = FakeLiveTvApi( + startResult = ApiResult.Success(LiveTvSessionStartResponse(sessionId = "s1", streamUrl = "/stream")), + ) + api.heartbeatResult = ApiResult.Error(404, "not_found", "gone") + val viewModel = track(LiveTvPlayerViewModel(LiveTvRepository(api))) + viewModel.start("ch-1") + advanceUntilIdle() + + assertEquals(listOf("s1"), api.heartbeatCalls) + } + @Test fun playerRejectsBlankStreamUrlAndReleasesSession() = runTest { val api = FakeLiveTvApi(