From f4877f13cdc71acdfd55be562249ca263b58d3fa Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:52:01 -0500 Subject: [PATCH 1/2] Move Live TV and quality ladder to /api/v2 Live TV viewer calls move to /api/v2/livetv: {items} envelopes, guide uses repeated channel_id, session start POSTs .../sessions and plays the returned stream_url (hls_url is gone), stop is 204, problem+json errors. A 409 capability_not_configured now hides the Live TV surface. The quality ladder moves to /api/v2/playback/quality-ladder. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ui/screens/player/PlayerOverlay.kt | 2 +- .../prairieserver/prairie/di/NetworkModule.kt | 6 +- .../model/feature/LiveTvFeatureStore.kt | 13 ++- .../prairie/model/livetv/LiveTvModels.kt | 41 +++------ .../prairie/network/api/LiveTvApi.kt | 91 +++++++++---------- .../prairie/network/api/PlaybackApi.kt | 6 +- .../prairie/playback/QualityLadder.kt | 2 +- .../prairie/repository/LiveTvRepository.kt | 3 +- .../prairie/repository/PlaybackRepository.kt | 2 +- .../model/feature/LiveTvFeatureStoreTest.kt | 16 ++++ .../prairie/network/api/LiveTvApiTest.kt | 64 +++++++++---- .../prairie/network/api/PlaybackApiTest.kt | 4 +- .../repository/LiveTvRepositoryTest.kt | 12 +-- .../prairie/viewmodel/LiveTvViewModelTest.kt | 9 +- 14 files changed, 151 insertions(+), 120 deletions(-) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt index 3923e05269..71624e414b 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt @@ -149,7 +149,7 @@ fun PlayerOverlay( playbackQualityMenu(state.playbackPlan?.availableQualities.orEmpty()) } // Prairie: when the plan lists no qualities, fall back to the Prairie - // quality ladder (QualityLadder, GET /api/v1/playback/quality-ladder) the + // quality ladder (QualityLadder, GET /api/v2/playback/quality-ladder) the // view model builds per file. Its picks restart the session through // onSelectQuality (PlayerViewModel.switchQuality). Not offered offline // (no session) or in a party, which plays exactly the room's stream. diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt index 5d3ba33503..dcf25e037e 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt @@ -40,7 +40,7 @@ val networkModule = module { single { PersonRefreshV2Api(get(), get(), get()) } single { WatchDetailV2Api(get(), get(), get()) } single { CatalogApi(get(), get(), get(), get()) } - // Prairie-only v1 routes: explicit transcode start + the transcode quality ladder. + // Prairie-only: the transcode quality ladder (/api/v2/playback/quality-ladder). single { PlaybackApi(get()) } single { org.prairieserver.prairie.playback.QualityLadderClient( @@ -59,8 +59,8 @@ val networkModule = module { single { ShufflesV2Api(get(), get(), get()) } single { RecommendationApi(get(), get(), get(), get()) } single { DefaultRequestsApi(get(), get(), get()) } - // Prairie-only: Live TV (Jellyfin-compat endpoints on prairie-server). - single { DefaultLiveTvApi(get()) } + // Prairie-only: Live TV (/api/v2/livetv on prairie-server). + single { DefaultLiveTvApi(get(), get()) } single { DefaultMetadataAiApi(get(), get(), get()) } single { EventsSocketV2Api(get(), get(), get(), getOrNull()) } single { DefaultHomeRealtimeClient(get()) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/LiveTvFeatureStore.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/LiveTvFeatureStore.kt index a2fcc23449..094c031226 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/LiveTvFeatureStore.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/feature/LiveTvFeatureStore.kt @@ -9,7 +9,7 @@ import org.prairieserver.prairie.repository.LiveTvRepository /** * Server-gated Live TV capability. * - * Entry points start hidden. A successful `/api/v1/livetv/channels` probe with a + * Entry points start hidden. A successful `/api/v2/livetv/channels` probe with a * nonempty channel list enables the surface. Transient failures keep the previous * value, and [reset] hides the surface before a server/profile switch can reuse * stale capability state — matching [RequestsFeatureStore]. @@ -33,9 +33,14 @@ class LiveTvFeatureStore( _isEnabled.value = result.data.channels.isNotEmpty() } } - is ApiResult.Error, - is ApiResult.NetworkError, - -> Unit + // Live TV switched off server-side: hide the surface. + is ApiResult.Error -> if ( + result.code == 409 && result.error == "capability_not_configured" && + refreshGeneration == generation + ) { + _isEnabled.value = false + } + is ApiResult.NetworkError -> Unit } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/livetv/LiveTvModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/livetv/LiveTvModels.kt index ebfe8552a9..4d11c0b20b 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/livetv/LiveTvModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/livetv/LiveTvModels.kt @@ -14,7 +14,6 @@ data class LiveTvChannel( @SerialName("logo_url") val logoUrl: String = "", val hd: Boolean = false, val enabled: Boolean = true, - @SerialName("stream_url") val streamUrl: String = "", @SerialName("guide_station_id") val guideStationId: String = "", ) { /** Prefer an admin override when present, otherwise the tuner lineup number. */ @@ -29,7 +28,7 @@ data class LiveTvChannel( @Serializable data class LiveTvChannelsResponse( - val channels: List = emptyList(), + @SerialName("items") val channels: List = emptyList(), ) @Serializable @@ -54,26 +53,28 @@ data class LiveTvProgram( @Serializable data class LiveTvGuideResponse( - val programs: List = emptyList(), + @SerialName("items") val programs: List = emptyList(), val start: String = "", val end: String = "", ) /** - * Response from `POST /api/v1/livetv/channels/{id}/session`. + * Response from `POST /api/v2/livetv/channels/{id}/sessions` (201). * - * [transport] is `"hls"` when the server HLS remux bridge is active, otherwise - * `"mpegts"` for the authenticated session MPEG-TS proxy. Clients play - * [playableUrl], then `DELETE /api/v1/livetv/sessions/{sessionId}` when stopped. + * [transport] is `"hls"` (live playlist) or `"mpegts"` (authenticated proxy). + * [streamUrl] is opaque and server-relative (still under `/api/v1/livetv`); + * play it as returned, then `DELETE /api/v2/livetv/sessions/{sessionId}`. */ @Serializable data class LiveTvSessionStartResponse( @SerialName("session_id") val sessionId: String, + @SerialName("channel_id") val channelId: String = "", @SerialName("playback_ticket") val playbackTicket: String = "", - @SerialName("hls_url") val hlsUrl: String = "", @SerialName("stream_url") val streamUrl: String = "", val transport: String = "mpegts", val note: String = "", + val status: String = "", + @SerialName("stale_after_seconds") val staleAfterSeconds: Int = 0, ) { /** True when the server (or URL shape) indicates HLS remux. */ val isHls: Boolean @@ -81,37 +82,21 @@ data class LiveTvSessionStartResponse( val explicit = transport.trim().lowercase() if (explicit == "hls") return true if (explicit == "mpegts") return false - val url = (hlsUrl.ifBlank { streamUrl }).lowercase() + val url = streamUrl.lowercase() return url.contains(".m3u8") || url.contains("/live-hls/") } val playableUrl: String - get() = when { - isHls -> hlsUrl.takeIf { it.isNotBlank() } ?: streamUrl - else -> streamUrl.takeIf { it.isNotBlank() } ?: hlsUrl - } + get() = streamUrl } -@Serializable -data class LiveTvSession( - val id: String, - @SerialName("channel_id") val channelId: String = "", - @SerialName("tuner_id") val tunerId: String = "", - @SerialName("tuner_index") val tunerIndex: Int = 0, - val status: String = "", - @SerialName("hls_url") val hlsUrl: String = "", - @SerialName("stream_url") val streamUrl: String = "", - val note: String = "", - @SerialName("created_at") val createdAt: String = "", - @SerialName("released_at") val releasedAt: String? = null, -) - @Serializable data class LiveTvRecording( val id: String, @SerialName("program_id") val programId: String? = null, @SerialName("channel_id") val channelId: String = "", @SerialName("series_rule_id") val seriesRuleId: String? = null, + @SerialName("profile_id") val profileId: String? = null, val status: String = "", // Intentionally omit server filesystem `path` from the client model. @SerialName("library_item_id") val libraryItemId: String? = null, @@ -130,7 +115,7 @@ data class LiveTvRecording( @Serializable data class LiveTvRecordingsResponse( - val recordings: List = emptyList(), + @SerialName("items") val recordings: List = emptyList(), ) /** Guide-based schedule body — server fills channel/window/title from program_id. */ 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 17960d6e21..749aae4066 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 @@ -1,11 +1,11 @@ package org.prairieserver.prairie.network.api import io.ktor.client.HttpClient -import io.ktor.client.request.delete -import io.ktor.client.request.get +import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.parameter -import io.ktor.client.request.post +import io.ktor.client.request.request import io.ktor.client.request.setBody +import io.ktor.http.HttpMethod import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.http.encodeURLPathPart @@ -15,12 +15,17 @@ import org.prairieserver.prairie.model.livetv.LiveTvProgram import org.prairieserver.prairie.model.livetv.LiveTvRecording import org.prairieserver.prairie.model.livetv.LiveTvRecordingsResponse import org.prairieserver.prairie.model.livetv.LiveTvScheduleRecordingRequest -import org.prairieserver.prairie.model.livetv.LiveTvSession import org.prairieserver.prairie.model.livetv.LiveTvSessionStartResponse import org.prairieserver.prairie.network.ApiResult +import org.prairieserver.prairie.network.apiv2.ApiV2Gate +import org.prairieserver.prairie.network.apiv2.safeApiV2Call /** - * User-facing Live TV endpoints under `/api/v1/livetv`. + * User-facing Live TV endpoints under `/api/v2/livetv`. + * + * Errors are problem+json; when Live TV is off every call is + * `409 capability_not_configured`, which callers treat as "unavailable". + * Live media still plays from the v1 `stream_url` the session returns. * * Kept behind an interface so repository / feature-store tests can fake the * transport, matching the Requests API shape. @@ -39,7 +44,7 @@ interface LiveTvApi { suspend fun startSession(channelId: String): ApiResult - suspend fun releaseSession(sessionId: String): ApiResult + suspend fun releaseSession(sessionId: String): ApiResult suspend fun recordings(status: String? = null): ApiResult @@ -48,61 +53,57 @@ interface LiveTvApi { suspend fun cancelRecording(recordingId: String): ApiResult } -class DefaultLiveTvApi(private val client: HttpClient) : LiveTvApi { - - override suspend fun channels(tunerId: String?): ApiResult = safeApiCall { - client.get("/api/v1/livetv/channels") { - if (!tunerId.isNullOrBlank()) { - parameter("tuner_id", tunerId) - } +class DefaultLiveTvApi( + private val client: HttpClient, + private val gate: ApiV2Gate = ApiV2Gate(), +) : LiveTvApi { + + private suspend inline fun call( + method: HttpMethod, + path: String, + noinline configure: HttpRequestBuilder.() -> Unit = {}, + ): ApiResult = safeApiV2Call(gate) { + client.request("/api/v2/livetv$path") { + this.method = method + configure() } } + override suspend fun channels(tunerId: String?): ApiResult = + call(HttpMethod.Get, "/channels") { + if (!tunerId.isNullOrBlank()) parameter("tuner_id", tunerId) + } + override suspend fun guide( channelIds: List, start: String?, end: String?, - ): ApiResult = safeApiCall { - client.get("/api/v1/livetv/guide") { - if (channelIds.isNotEmpty()) { - parameter("channels", channelIds.joinToString(",")) - } - if (!start.isNullOrBlank()) { - parameter("start", start) - } - if (!end.isNullOrBlank()) { - parameter("end", end) - } - } + ): ApiResult = call(HttpMethod.Get, "/guide") { + channelIds.forEach { parameter("channel_id", it) } + if (!start.isNullOrBlank()) parameter("start", start) + if (!end.isNullOrBlank()) parameter("end", end) } - override suspend fun program(programId: String): ApiResult = safeApiCall { - client.get("/api/v1/livetv/programs/${programId.encodeURLPathPart()}") - } + override suspend fun program(programId: String): ApiResult = + call(HttpMethod.Get, "/programs/${programId.encodeURLPathPart()}") override suspend fun startSession(channelId: String): ApiResult = - safeApiCall { - client.post("/api/v1/livetv/channels/${channelId.encodeURLPathPart()}/session") - } + call(HttpMethod.Post, "/channels/${channelId.encodeURLPathPart()}/sessions") - override suspend fun releaseSession(sessionId: String): ApiResult = safeApiCall { - client.delete("/api/v1/livetv/sessions/${sessionId.encodeURLPathPart()}") - } + override suspend fun releaseSession(sessionId: String): ApiResult = + call(HttpMethod.Delete, "/sessions/${sessionId.encodeURLPathPart()}") - override suspend fun recordings(status: String?): ApiResult = safeApiCall { - client.get("/api/v1/livetv/recordings") { - if (!status.isNullOrBlank()) { - parameter("status", status) - } + override suspend fun recordings(status: String?): ApiResult = + call(HttpMethod.Get, "/recordings") { + if (!status.isNullOrBlank()) parameter("status", status) } - } override suspend fun scheduleRecording( request: LiveTvScheduleRecordingRequest, - ): ApiResult = safeApiCall { + ): ApiResult { val programId = request.programId?.trim().orEmpty() - require(programId.isNotEmpty()) { "program_id is required" } - client.post("/api/v1/livetv/recordings") { + if (programId.isEmpty()) return ApiResult.Error(422, "validation_failed", "program_id is required") + return call(HttpMethod.Post, "/recordings") { contentType(ContentType.Application.Json) // Guide-based schedule: server fills channel/window/title from program_id. setBody(LiveTvScheduleRecordingRequest(programId = programId)) @@ -110,7 +111,5 @@ class DefaultLiveTvApi(private val client: HttpClient) : LiveTvApi { } override suspend fun cancelRecording(recordingId: String): ApiResult = - safeApiCall { - client.delete("/api/v1/livetv/recordings/${recordingId.encodeURLPathPart()}") - } + call(HttpMethod.Delete, "/recordings/${recordingId.encodeURLPathPart()}") } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/PlaybackApi.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/PlaybackApi.kt index 6d5a5a7e45..f4644ab461 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/PlaybackApi.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/api/PlaybackApi.kt @@ -8,16 +8,16 @@ import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.playback.QualityLadderResponse /** - * Prairie-only v1 playback route: the transcode quality ladder. + * Prairie-only playback route: the transcode quality ladder. * * Upstream dropped the rest of the v1 playback transport in favour of * SequencedPlayback + PlaybackV2Api; only the Prairie additions remain here. */ class PlaybackApi(private val client: HttpClient) { - /** Server's transcode quality ladder (`GET /api/v1/playback/quality-ladder`). */ + /** Server's transcode quality ladder (`GET /api/v2/playback/quality-ladder`; auth, no profile). */ suspend fun getQualityLadder(sourceHeight: Int? = null): ApiResult = safeApiCall { - client.get("/api/v1/playback/quality-ladder") { + client.get("/api/v2/playback/quality-ladder") { if (sourceHeight != null && sourceHeight > 0) { parameter("source_height", sourceHeight) } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/QualityLadder.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/QualityLadder.kt index 438cf75256..ee3f12e6d6 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/QualityLadder.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/QualityLadder.kt @@ -17,7 +17,7 @@ data class QualityLadderRung( @SerialName("bitrate_kbps") val bitrateKbps: Int, ) -/** Picker payload from `GET /api/v1/playback/quality-ladder`. */ +/** Picker payload from `GET /api/v2/playback/quality-ladder`. */ @Serializable data class QualityLadderResponse( val rungs: List = emptyList(), 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 a924c81693..bb81da4c38 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/LiveTvRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/LiveTvRepository.kt @@ -10,7 +10,6 @@ import org.prairieserver.prairie.model.livetv.LiveTvProgram import org.prairieserver.prairie.model.livetv.LiveTvRecording import org.prairieserver.prairie.model.livetv.LiveTvRecordingsResponse import org.prairieserver.prairie.model.livetv.LiveTvScheduleRecordingRequest -import org.prairieserver.prairie.model.livetv.LiveTvSession import org.prairieserver.prairie.model.livetv.LiveTvSessionStartResponse import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.LiveTvApi @@ -40,7 +39,7 @@ class LiveTvRepository(private val api: LiveTvApi) { suspend fun startSession(channelId: String): ApiResult = api.startSession(channelId) - suspend fun releaseSession(sessionId: String): ApiResult = + suspend fun releaseSession(sessionId: String): ApiResult = api.releaseSession(sessionId) suspend fun recordings(status: String? = null): ApiResult = diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt index f8d3b39448..30a0a9e98c 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/repository/PlaybackRepository.kt @@ -11,7 +11,7 @@ import org.prairieserver.prairie.playback.QualityLadderResponse import org.prairieserver.prairie.network.AuthScopeSnapshot /** - * [transcodeApi] carries the Prairie-only v1 quality-ladder route; it is + * [transcodeApi] carries the Prairie-only quality-ladder route (v2); it is * optional so upstream call sites that only drive [SequencedPlayback] keep working. */ class PlaybackRepository( 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 41c6f7c911..4b5687b63f 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 @@ -59,6 +59,22 @@ class LiveTvFeatureStoreTest { assertTrue(store.isEnabled.value) } + @Test + fun capabilityNotConfiguredHidesLiveTv() = runTest { + val api = FakeLiveTvApi( + channelsResult = ApiResult.Success( + LiveTvChannelsResponse(listOf(LiveTvChannel(id = "1"))), + ), + ) + val store = LiveTvFeatureStore(LiveTvRepository(api)) + store.refresh() + assertTrue(store.isEnabled.value) + + api.channelsResult = ApiResult.Error(409, "capability_not_configured", "Live TV is not configured.") + store.refresh() + assertFalse(store.isEnabled.value) + } + @Test fun resetHidesLiveTvBeforeNextProbe() = runTest { val store = LiveTvFeatureStore( 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 e8f940d801..fe7be38523 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 @@ -28,7 +28,7 @@ class LiveTvApiTest { private fun api( captured: Captured, - body: String = """{"channels":[]}""", + body: String = """{"items":[]}""", status: HttpStatusCode = HttpStatusCode.OK, ): LiveTvApi { val client = HttpClient( @@ -54,11 +54,11 @@ class LiveTvApiTest { val captured = Captured() val result = api( captured, - body = """{"channels":[{"id":"ch-1","number":"7.1","name":"KGO","enabled":true}]}""", + body = """{"items":[{"id":"ch-1","number":"7.1","name":"KGO","enabled":true}]}""", ).channels(tunerId = "tuner-1") assertEquals(HttpMethod.Get, captured.method) - assertEquals("/api/v1/livetv/channels", captured.path) + assertEquals("/api/v2/livetv/channels", captured.path) assertTrue(captured.query.contains("tuner_id=tuner-1")) val success = assertIs>(result) val response = success.data as org.prairieserver.prairie.model.livetv.LiveTvChannelsResponse @@ -67,17 +67,20 @@ class LiveTvApiTest { } @Test - fun guideJoinsChannelIdsAndPassesWindow() = runTest { + fun guideRepeatsChannelIdAndParsesItems() = runTest { val captured = Captured() - api(captured, body = """{"programs":[],"start":"","end":""}""").guide( + val result = api(captured, body = """{"items":[{"id":"p1","channel_id":"a","title":"News"}],"start":"","end":""}""").guide( channelIds = listOf("a", "b"), start = "2026-07-25T00:00:00Z", end = "2026-07-25T12:00:00Z", ) assertEquals(HttpMethod.Get, captured.method) - assertEquals("/api/v1/livetv/guide", captured.path) - assertTrue(captured.query.contains("channels=a%2Cb") || captured.query.contains("channels=a,b")) + assertEquals("/api/v2/livetv/guide", captured.path) + assertTrue(captured.query.contains("channel_id=a&channel_id=b")) + assertTrue(!captured.query.contains("channels=")) + val guide = assertIs>(result).data as org.prairieserver.prairie.model.livetv.LiveTvGuideResponse + assertEquals("p1", guide.programs.single().id) assertTrue(captured.query.contains("start=2026-07-25T00%3A00%3A00Z") || captured.query.contains("start=2026-07-25T00:00:00Z")) } @@ -86,18 +89,19 @@ class LiveTvApiTest { val captured = Captured() val result = api( captured, - body = """{"session_id":"s1","playback_ticket":"t1","hls_url":"/api/v1/livetv/live-hls/t1/index.m3u8","transport":"hls"}""", + body = """{"session_id":"s1","channel_id":"ch-9","playback_ticket":"t1","stream_url":"/api/v1/livetv/live-hls/t1/index.m3u8?st=x","transport":"hls","status":"active","created_at":"2026-10-12T00:00:00Z","stale_after_seconds":90}""", status = HttpStatusCode.Created, ).startSession("ch-9") assertEquals(HttpMethod.Post, captured.method) - assertEquals("/api/v1/livetv/channels/ch-9/session", captured.path) + assertEquals("/api/v2/livetv/channels/ch-9/sessions", captured.path) val success = assertIs>(result) val session = success.data as org.prairieserver.prairie.model.livetv.LiveTvSessionStartResponse assertEquals("s1", session.sessionId) assertEquals("hls", session.transport) assertTrue(session.isHls) - assertEquals("/api/v1/livetv/live-hls/t1/index.m3u8", session.playableUrl) + assertEquals("/api/v1/livetv/live-hls/t1/index.m3u8?st=x", session.playableUrl) + assertEquals(90, session.staleAfterSeconds) } @Test @@ -119,13 +123,11 @@ class LiveTvApiTest { @Test fun releaseSessionDeletesSession() = runTest { val captured = Captured() - api( - captured, - body = """{"id":"s1","channel_id":"ch-9","status":"released"}""", - ).releaseSession("s1") + val result = api(captured, body = "", status = HttpStatusCode.NoContent).releaseSession("s1") assertEquals(HttpMethod.Delete, captured.method) - assertEquals("/api/v1/livetv/sessions/s1", captured.path) + assertEquals("/api/v2/livetv/sessions/s1", captured.path) + assertIs>(result) } @Test @@ -137,7 +139,7 @@ class LiveTvApiTest { ).program("p1") assertEquals(HttpMethod.Get, captured.method) - assertEquals("/api/v1/livetv/programs/p1", captured.path) + assertEquals("/api/v2/livetv/programs/p1", captured.path) } @Test @@ -152,10 +154,38 @@ class LiveTvApiTest { ) assertEquals(HttpMethod.Post, captured.method) - assertEquals("/api/v1/livetv/recordings", captured.path) + assertEquals("/api/v2/livetv/recordings", captured.path) assertTrue(captured.body.contains("program_id")) val success = assertIs>(result) val recording = success.data as org.prairieserver.prairie.model.livetv.LiveTvRecording assertEquals("r1", recording.id) } + + @Test + fun recordingsParseItemsEnvelope() = runTest { + val captured = Captured() + val result = api( + captured, + body = """{"items":[{"id":"r1","channel_id":"ch-1","profile_id":"pr-1","status":"scheduled","title":"News","start":null}]}""", + ).recordings(status = "scheduled") + + assertEquals("/api/v2/livetv/recordings", captured.path) + assertTrue(captured.query.contains("status=scheduled")) + val response = assertIs>(result).data as org.prairieserver.prairie.model.livetv.LiveTvRecordingsResponse + assertEquals("r1", response.recordings.single().id) + assertEquals("pr-1", response.recordings.single().profileId) + } + + @Test + fun capabilityNotConfiguredSurfacesAsProblemCode() = runTest { + val result = api( + Captured(), + body = """{"type":"https://prairieserver.org/docs/api/v2/problems/capability_not_configured","title":"Conflict","status":409,"detail":"Live TV is not configured."}""", + status = HttpStatusCode.Conflict, + ).channels() + + val error = assertIs(result) + assertEquals(409, error.code) + assertEquals("capability_not_configured", error.error) + } } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/PlaybackApiTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/PlaybackApiTest.kt index 52a5a81095..268c2c5ba5 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/PlaybackApiTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/network/api/PlaybackApiTest.kt @@ -62,7 +62,7 @@ class PlaybackApiTest { ).getQualityLadder() assertEquals(HttpMethod.Get, captured.method) - assertEquals("/api/v1/playback/quality-ladder", captured.path) + assertEquals("/api/v2/playback/quality-ladder", captured.path) assertTrue(captured.query.isEmpty()) val success = assertIs>(result) assertEquals(1, success.data.rungs.size) @@ -80,7 +80,7 @@ class PlaybackApiTest { api(captured).getQualityLadder(sourceHeight = 2160) assertEquals(HttpMethod.Get, captured.method) - assertEquals("/api/v1/playback/quality-ladder", captured.path) + assertEquals("/api/v2/playback/quality-ladder", captured.path) assertEquals("2160", captured.query["source_height"]) } } 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 1665ff89ee..9b986fa2a3 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/LiveTvRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/repository/LiveTvRepositoryTest.kt @@ -5,7 +5,6 @@ import org.prairieserver.prairie.model.livetv.LiveTvChannel import org.prairieserver.prairie.model.livetv.LiveTvChannelsResponse import org.prairieserver.prairie.model.livetv.LiveTvGuideResponse import org.prairieserver.prairie.model.livetv.LiveTvProgram -import org.prairieserver.prairie.model.livetv.LiveTvSession import org.prairieserver.prairie.model.livetv.LiveTvSessionStartResponse import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.network.api.LiveTvApi @@ -52,9 +51,9 @@ class LiveTvRepositoryTest { fun startAndReleasePassThrough() = runTest { val api = FakeLiveTvApi( startResult = ApiResult.Success( - LiveTvSessionStartResponse(sessionId = "s1", hlsUrl = "/live.m3u8"), + LiveTvSessionStartResponse(sessionId = "s1", streamUrl = "/live.m3u8"), ), - releaseResult = ApiResult.Success(LiveTvSession(id = "s1", status = "released")), + releaseResult = ApiResult.Success(Unit), ) val repo = LiveTvRepository(api) @@ -62,8 +61,7 @@ class LiveTvRepositoryTest { assertEquals("s1", started.data.sessionId) assertEquals(1, api.startCalls) - val released = assertIs>(repo.releaseSession("s1")) - assertEquals("released", released.data.status) + assertIs>(repo.releaseSession("s1")) assertEquals(listOf("s1"), api.releaseCalls) } @@ -106,7 +104,7 @@ internal class FakeLiveTvApi( ApiResult.NetworkError(IllegalStateException("unused")), var startResult: ApiResult = ApiResult.NetworkError(IllegalStateException("unused")), - var releaseResult: ApiResult = + var releaseResult: ApiResult = ApiResult.NetworkError(IllegalStateException("unused")), ) : LiveTvApi { var startCalls = 0 @@ -127,7 +125,7 @@ internal class FakeLiveTvApi( return startResult } - override suspend fun releaseSession(sessionId: String): ApiResult { + 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 5d9d2dc1f1..d9e28347b1 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModelTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModelTest.kt @@ -13,7 +13,6 @@ import org.prairieserver.prairie.model.livetv.LiveTvChannel import org.prairieserver.prairie.model.livetv.LiveTvChannelsResponse import org.prairieserver.prairie.model.livetv.LiveTvGuideResponse import org.prairieserver.prairie.model.livetv.LiveTvProgram -import org.prairieserver.prairie.model.livetv.LiveTvSession import org.prairieserver.prairie.model.livetv.LiveTvSessionStartResponse import org.prairieserver.prairie.network.ApiResult import org.prairieserver.prairie.repository.FakeLiveTvApi @@ -110,11 +109,11 @@ class LiveTvViewModelTest { startResult = ApiResult.Success( LiveTvSessionStartResponse( sessionId = "s1", - hlsUrl = "/api/v1/livetv/live-hls/s1/index.m3u8", + streamUrl = "/api/v1/livetv/live-hls/s1/index.m3u8", transport = "hls", ), ), - releaseResult = ApiResult.Success(LiveTvSession(id = "s1", status = "released")), + releaseResult = ApiResult.Success(Unit), ) val viewModel = track(LiveTvPlayerViewModel(LiveTvRepository(api))) @@ -137,9 +136,9 @@ class LiveTvViewModelTest { fun playerRejectsBlankStreamUrlAndReleasesSession() = runTest { val api = FakeLiveTvApi( startResult = ApiResult.Success( - LiveTvSessionStartResponse(sessionId = "s1", hlsUrl = "", streamUrl = ""), + LiveTvSessionStartResponse(sessionId = "s1", streamUrl = ""), ), - releaseResult = ApiResult.Success(LiveTvSession(id = "s1", status = "released")), + releaseResult = ApiResult.Success(Unit), ) val viewModel = track(LiveTvPlayerViewModel(LiveTvRepository(api))) viewModel.start("ch-1") From cacb4645f86794e67d50dccbe7b6e070fc7650e2 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:07:12 -0500 Subject: [PATCH 2/2] test: join HomeViewModel scopes before resetMain realtimeSignalsDuringARefreshRunOneMoreRefreshAfterIt returned while the follow-up fetch was still out on the MockEngine's IO thread. It then resumed onto a reset Dispatchers.Main and failed the next runTest (LiveTvViewModelTest) with UncaughtExceptionsBeforeTest. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../viewmodel/HomeViewModelCacheIdentityTest.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelCacheIdentityTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelCacheIdentityTest.kt index 194a9e51db..fd5a882ff5 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelCacheIdentityTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/viewmodel/HomeViewModelCacheIdentityTest.kt @@ -10,9 +10,12 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain @@ -90,6 +93,7 @@ class HomeViewModelCacheIdentityTest { viewModel.uiState.first { !it.isLoading } assertEquals(null, cache.sections) + viewModel.close() } @Test @@ -123,6 +127,7 @@ class HomeViewModelCacheIdentityTest { // The repository removes empty rows during hydration; diagnostics see // only the aggregate resolved count, never the private row metadata. assertEquals(listOf(0, 0), observations.map { it.sectionCount }) + viewModel.close() } @Test @@ -164,6 +169,14 @@ class HomeViewModelCacheIdentityTest { releaseRealtime.complete(Unit) followUpEntered.await() + viewModel.close() + } + + // Cancel and join before tearDown resets Main: a fetch still out on the + // MockEngine's IO thread would otherwise resume onto a missing Main + // dispatcher and fail whichever runTest comes next. + private suspend fun HomeViewModel.close() { + viewModelScope.coroutineContext[Job]?.cancelAndJoin() } private class RecordingHomeCache : HomeCachePort {