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 @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -59,8 +59,8 @@ val networkModule = module {
single { ShufflesV2Api(get(), get(), get()) }
single { RecommendationApi(get(), get(), get(), get()) }
single<RequestsApi> { DefaultRequestsApi(get(), get(), get()) }
// Prairie-only: Live TV (Jellyfin-compat endpoints on prairie-server).
single<LiveTvApi> { DefaultLiveTvApi(get()) }
// Prairie-only: Live TV (/api/v2/livetv on prairie-server).
single<LiveTvApi> { DefaultLiveTvApi(get(), get()) }
single<MetadataAiApi> { DefaultMetadataAiApi(get(), get(), get()) }
single { EventsSocketV2Api(get(), get(), get(), getOrNull()) }
single<HomeRealtimeClient> { DefaultHomeRealtimeClient(get()) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand All @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -29,7 +28,7 @@ data class LiveTvChannel(

@Serializable
data class LiveTvChannelsResponse(
val channels: List<LiveTvChannel> = emptyList(),
@SerialName("items") val channels: List<LiveTvChannel> = emptyList(),
)

@Serializable
Expand All @@ -54,64 +53,50 @@ data class LiveTvProgram(

@Serializable
data class LiveTvGuideResponse(
val programs: List<LiveTvProgram> = emptyList(),
@SerialName("items") val programs: List<LiveTvProgram> = 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
get() {
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,
Expand All @@ -130,7 +115,7 @@ data class LiveTvRecording(

@Serializable
data class LiveTvRecordingsResponse(
val recordings: List<LiveTvRecording> = emptyList(),
@SerialName("items") val recordings: List<LiveTvRecording> = emptyList(),
)

/** Guide-based schedule body — server fills channel/window/title from program_id. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -39,7 +44,7 @@ interface LiveTvApi {

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

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

suspend fun recordings(status: String? = null): ApiResult<LiveTvRecordingsResponse>

Expand All @@ -48,69 +53,63 @@ interface LiveTvApi {
suspend fun cancelRecording(recordingId: String): ApiResult<LiveTvRecording>
}

class DefaultLiveTvApi(private val client: HttpClient) : LiveTvApi {

override suspend fun channels(tunerId: String?): ApiResult<LiveTvChannelsResponse> = 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 <reified T> call(
method: HttpMethod,
path: String,
noinline configure: HttpRequestBuilder.() -> Unit = {},
): ApiResult<T> = safeApiV2Call(gate) {
client.request("/api/v2/livetv$path") {
this.method = method
configure()
}
}

override suspend fun channels(tunerId: String?): ApiResult<LiveTvChannelsResponse> =
call(HttpMethod.Get, "/channels") {
if (!tunerId.isNullOrBlank()) parameter("tuner_id", tunerId)
}

override suspend fun guide(
channelIds: List<String>,
start: String?,
end: String?,
): ApiResult<LiveTvGuideResponse> = 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<LiveTvGuideResponse> = 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<LiveTvProgram> = safeApiCall {
client.get("/api/v1/livetv/programs/${programId.encodeURLPathPart()}")
}
override suspend fun program(programId: String): ApiResult<LiveTvProgram> =
call(HttpMethod.Get, "/programs/${programId.encodeURLPathPart()}")

override suspend fun startSession(channelId: String): ApiResult<LiveTvSessionStartResponse> =
safeApiCall {
client.post("/api/v1/livetv/channels/${channelId.encodeURLPathPart()}/session")
}
call(HttpMethod.Post, "/channels/${channelId.encodeURLPathPart()}/sessions")

override suspend fun releaseSession(sessionId: String): ApiResult<LiveTvSession> = safeApiCall {
client.delete("/api/v1/livetv/sessions/${sessionId.encodeURLPathPart()}")
}
override suspend fun releaseSession(sessionId: String): ApiResult<Unit> =
call(HttpMethod.Delete, "/sessions/${sessionId.encodeURLPathPart()}")

override suspend fun recordings(status: String?): ApiResult<LiveTvRecordingsResponse> = safeApiCall {
client.get("/api/v1/livetv/recordings") {
if (!status.isNullOrBlank()) {
parameter("status", status)
}
override suspend fun recordings(status: String?): ApiResult<LiveTvRecordingsResponse> =
call(HttpMethod.Get, "/recordings") {
if (!status.isNullOrBlank()) parameter("status", status)
}
}

override suspend fun scheduleRecording(
request: LiveTvScheduleRecordingRequest,
): ApiResult<LiveTvRecording> = safeApiCall {
): ApiResult<LiveTvRecording> {
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))
}
}

override suspend fun cancelRecording(recordingId: String): ApiResult<LiveTvRecording> =
safeApiCall {
client.delete("/api/v1/livetv/recordings/${recordingId.encodeURLPathPart()}")
}
call(HttpMethod.Delete, "/recordings/${recordingId.encodeURLPathPart()}")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<QualityLadderResponse> = safeApiCall {
client.get("/api/v1/playback/quality-ladder") {
client.get("/api/v2/playback/quality-ladder") {
if (sourceHeight != null && sourceHeight > 0) {
parameter("source_height", sourceHeight)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<QualityLadderRung> = emptyList(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,7 +39,7 @@ class LiveTvRepository(private val api: LiveTvApi) {
suspend fun startSession(channelId: String): ApiResult<LiveTvSessionStartResponse> =
api.startSession(channelId)

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

suspend fun recordings(status: String? = null): ApiResult<LiveTvRecordingsResponse> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading