EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

RadioStatusClient.kt

Each line, with the release that last changed it.

0.5.01package dev.stave.cast.radio
2
3import kotlinx.coroutines.Dispatchers
4import kotlinx.coroutines.withContext
5import org.json.JSONObject
6import java.net.HttpURLConnection
7import java.net.URL
8import java.net.URLEncoder
9
10/**
11 * One read of musicnow's `/status` for a speaker group (ported from Android,
12 * design decision, 2026-09-04): when the group plays a radio station,
13 * musicnow knows the song, artist and art the station is broadcasting and
14 * serves them here. The desktop shows them over the station's name. The
15 * address is a setting (`SettingsStore.musicnowStatusUrl`); blank = off.
16 *
17 * The result distinguishes the three cases Android learned to keep apart
18 * (fleet-wide flash-then-default incident, 2026-08-29):
19 * - [Poll.Station] — radio with real metadata.
20 * - [Poll.NoStation] — HTTP 200 with `station: null`: a library session (or
21 * a station that publishes nothing). Counts toward the
22 * caller's latch.
23 * - [Poll.Failed] — timeout, non-200, malformed body. Says NOTHING about
24 * the session: the caller must keep the last good
25 * overlay on screen and back off, never blank it.
26 * - [Poll.Disabled] — no address configured.
27 *
28 * Polling cadence/latching lives in the caller (RawCastObserver), which owns
29 * the session lifetime. musicnow's HTTP server handles one request at a time
30 * and other clients already poll it — callers must not shorten the
31 * interval below [POLL_INTERVAL_MS].
32 */
33class RadioStatusClient(private val settings: dev.stave.data.SettingsStore) {
34 data class RadioNow(val station: String, val title: String?, val artist: String?, val albumArtUrl: String?)
35
36 sealed interface Poll {
37 data class Station(val now: RadioNow) : Poll
38 data object NoStation : Poll
39 data object Failed : Poll
40 data object Disabled : Poll
41 }
42
43 suspend fun poll(group: String): Poll = withContext(Dispatchers.IO) {
44 val base = settings.musicnowStatusUrl.value.trim()
45 if (base.isBlank()) return@withContext Poll.Disabled
46 try {
47 val conn = (URL("$base?group=${URLEncoder.encode(group, "UTF-8")}").openConnection() as HttpURLConnection)
48 .apply { connectTimeout = 4_000; readTimeout = 4_000; requestMethod = "GET" }
49 try {
50 if (conn.responseCode != 200) {
51 System.err.println("[EarhornRadio] GET /status for '$group' -> HTTP ${conn.responseCode}")
52 return@withContext Poll.Failed
53 }
54 val json = JSONObject(conn.inputStream.bufferedReader().use { it.readText() })
55 // Never optString: it returns the literal "null" for a JSON
56 // null, which turned every library session into "radio".
57 fun str(k: String): String? = if (json.isNull(k)) null else json.optString(k).trim().ifBlank { null }
58 val station = str("station") ?: return@withContext Poll.NoStation
59 Poll.Station(RadioNow(station, str("title"), str("artist"), str("album_art")))
60 } finally {
61 conn.disconnect()
62 }
63 } catch (e: java.io.IOException) {
64 System.err.println("[EarhornRadio] /status for '$group' failed: ${e.javaClass.simpleName}: ${e.message}")
65 Poll.Failed
66 } catch (e: org.json.JSONException) {
67 System.err.println("[EarhornRadio] /status for '$group' returned malformed JSON: ${e.message}")
68 Poll.Failed
69 } catch (e: IllegalArgumentException) {
70 // Malformed URL in settings.
71 System.err.println("[EarhornRadio] musicnow address is not a valid URL: ${e.message}")
72 Poll.Failed
73 }
74 }
75
76 /** Back-compat single read: the overlay or null. Prefer [poll]. */
77 suspend fun fetch(group: String): RadioNow? = (poll(group) as? Poll.Station)?.now
78
79 companion object {
80 /** Within the memo's 10–15 s. Do not shorten (see class KDoc). */
81 const val POLL_INTERVAL_MS = 12_000L
82
83 /** Consecutive station:null reads tolerated before latching off —
84 * covers musicnow's ~20 s pre-fetch window at the start of a radio
85 * session (3 × 12 s) without polling forever on library sessions. */
86 const val NULL_READS_BEFORE_LATCH = 3
87
88 /** Failure backoff ceiling. */
89 const val MAX_BACKOFF_MS = 60_000L
90 }
91}