Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ interface LiveTvApi {

suspend fun startSession(channelId: String): ApiResult<LiveTvSessionStartResponse>

suspend fun heartbeatSession(sessionId: String): ApiResult<Unit>

suspend fun releaseSession(sessionId: String): ApiResult<Unit>

suspend fun recordings(status: String? = null): ApiResult<LiveTvRecordingsResponse>
Expand Down Expand Up @@ -90,6 +92,9 @@ class DefaultLiveTvApi(
override suspend fun startSession(channelId: String): ApiResult<LiveTvSessionStartResponse> =
call(HttpMethod.Post, "/channels/${channelId.encodeURLPathPart()}/sessions")

override suspend fun heartbeatSession(sessionId: String): ApiResult<Unit> =
call(HttpMethod.Post, "/sessions/${sessionId.encodeURLPathPart()}/heartbeat")

override suspend fun releaseSession(sessionId: String): ApiResult<Unit> =
call(HttpMethod.Delete, "/sessions/${sessionId.encodeURLPathPart()}")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ class LiveTvRepository(private val api: LiveTvApi) {
suspend fun startSession(channelId: String): ApiResult<LiveTvSessionStartResponse> =
api.startSession(channelId)

suspend fun heartbeatSession(sessionId: String): ApiResult<Unit> =
api.heartbeatSession(sessionId)

suspend fun releaseSession(sessionId: String): ApiResult<Unit> =
api.releaseSession(sessionId)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -280,6 +282,7 @@ class LiveTvPlayerViewModel(

private val _uiState = MutableStateFlow(LiveTvPlayerUiState())
val uiState: StateFlow<LiveTvPlayerUiState> = _uiState.asStateFlow()
private var heartbeatJob: Job? = null

fun start(channelId: String, channelName: String = "") {
if (channelId.isBlank()) {
Expand All @@ -288,6 +291,7 @@ class LiveTvPlayerViewModel(
}
return
}
heartbeatJob?.cancel()
viewModelScope.launch {
val previousSessionId = _uiState.value.session?.sessionId
if (!previousSessionId.isNullOrBlank()) {
Expand Down Expand Up @@ -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 -> {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiResult.Success<Unit>>(result)
}

@Test
fun releaseSessionDeletesSession() = runTest {
val captured = Captured()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ internal class FakeLiveTvApi(
) : LiveTvApi {
var startCalls = 0
val releaseCalls = mutableListOf<String>()
val heartbeatCalls = mutableListOf<String>()
var heartbeatResult: ApiResult<Unit> = ApiResult.Success(Unit)

override suspend fun channels(tunerId: String?): ApiResult<LiveTvChannelsResponse> = channelsResult

Expand All @@ -125,6 +127,11 @@ internal class FakeLiveTvApi(
return startResult
}

override suspend fun heartbeatSession(sessionId: String): ApiResult<Unit> {
heartbeatCalls += sessionId
return heartbeatResult
}

override suspend fun releaseSession(sessionId: String): ApiResult<Unit> {
releaseCalls += sessionId
return releaseResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down
Loading