RadioStatusClient.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.cast.radio |
| 2 | ||
| 3 | import kotlinx.coroutines.CoroutineScope | |
| 4 | import kotlinx.coroutines.Dispatchers | |
| 5 | import kotlinx.coroutines.Job | |
| 6 | import kotlinx.coroutines.SupervisorJob | |
| 7 | import kotlinx.coroutines.delay | |
| 8 | import kotlinx.coroutines.flow.MutableStateFlow | |
| 9 | import kotlinx.coroutines.flow.StateFlow | |
| 10 | import kotlinx.coroutines.flow.asStateFlow | |
| 11 | import kotlinx.coroutines.isActive | |
| 12 | import kotlinx.coroutines.launch | |
| 13 | import kotlinx.coroutines.withContext | |
| 14 | import org.json.JSONObject | |
| 15 | import java.net.HttpURLConnection | |
| 16 | import java.net.URL | |
| 17 | import java.net.URLEncoder | |
| 18 | import javax.inject.Inject | |
| 19 | import javax.inject.Singleton | |
| 20 | ||
| 21 | /** | |
| 22 | * Live song metadata for internet-radio sessions, read from musicnow's | |
| 23 | * existing `/status` endpoint (voice memo 2026-08-27). | |
| 24 | * | |
| 25 | * Radio sessions carry no song in the Cast media session — musicnow gives | |
| 26 | * the receiver a stream URL and a static station title, and the receiver | |
| 27 | * never learns the song. musicnow reads it station-side (one fetch per | |
| 28 | * playing station for the whole house) and publishes it on `/status`. This | |
| 29 | * client reads that field. It opens NO connection to any Cast device, does | |
| 30 | * no ICY, and fetches nothing from stations. | |
| 31 | * | |
| 32 | * `station` is the whole decision, per the memo: | |
| 33 | * - non-null → radio with real metadata; title/artist/album_art are the | |
| 34 | * song's, and polling continues. | |
| 35 | * - null → library playback, or radio from a station that publishes | |
| 36 | * nothing. The Cast session is authoritative; this client | |
| 37 | * emits null and STOPS POLLING for the session. | |
| 38 | * - any failure (unreachable, non-200, malformed) → emits null, stops. | |
| 39 | * Never an error state on a fixed screen. | |
| 40 | * | |
| 41 | * Polling discipline is a hard constraint, not a preference: musicnow's | |
| 42 | * HTTP server handles one request at a time and other screens already | |
| 43 | * poll it. Callers must [start] only while Now Playing is actually on | |
| 44 | * screen and [stop] on leaving it, pause of the observation, or hand-back. | |
| 45 | * Consecutive `station == null` reads latch this client off until the next | |
| 46 | * [start] (the caller re-arms on session content change), so a library | |
| 47 | * session costs a few requests at attach and nothing thereafter. | |
| 48 | */ | |
| 49 | @Singleton | |
| 50 | class RadioStatusClient @Inject constructor() { | |
| 51 | ||
| 52 | /** One now-playing snapshot from /status, only ever populated when | |
| 53 | * `station` was non-null. [album] from /status is deliberately NOT | |
| 54 | * carried: it arrives verbatim from the station and is often the | |
| 55 | * literal "Unknown Album" (server-side filtering declined by the | |
| 56 | * maintainer), and Now Playing shows the station on that line instead. */ | |
| 57 | data class RadioNow( | |
| 58 | val station: String, | |
| 59 | val title: String?, | |
| 60 | val artist: String?, | |
| 61 | val albumArtUrl: String?, | |
| 62 | ) | |
| 63 | ||
| 64 | private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) | |
| 65 | ||
| 66 | private val _now = MutableStateFlow<RadioNow?>(null) | |
| 67 | val now: StateFlow<RadioNow?> = _now.asStateFlow() | |
| 68 | ||
| 69 | private var pollJob: Job? = null | |
| 70 | private var activeGroup: String? = null | |
| 71 | ||
| 72 | private sealed interface Poll { | |
| 73 | data class Station(val now: RadioNow) : Poll | |
| 74 | /** 200 with station:null — a genuine library session; counts | |
| 75 | * toward the latch. */ | |
| 76 | data object NoStation : Poll | |
| 77 | /** Timeout, non-200, or exception — says nothing about the | |
| 78 | * session. Never latches, never clears the display. */ | |
| 79 | data object Failed : Poll | |
| 80 | } | |
| 81 | ||
| 82 | /** Interest refcount. Single-owner tokens FAILED in the field | |
| 83 | * (2026-08-29 17:11:46.248): during a nav-transition overlap the | |
| 84 | * DYING composition's gate re-ran on the contentKey change, hit the | |
| 85 | * idempotent fast-path, and was handed the current token — so its | |
| 86 | * teardown carried a valid credential and wiped live metadata one | |
| 87 | * second after paint. With two legitimate owners and unspecified | |
| 88 | * teardown order, ownership is the wrong model. Compositions now | |
| 89 | * ACQUIRE interest and RELEASE it; polling runs while any live | |
| 90 | * holder exists for the active group, so the outgoing composition's | |
| 91 | * release is harmless while the incoming one still holds. */ | |
| 92 | private var tokenSeq = 0L | |
| 93 | private val holders = mutableMapOf<Long, String>() | |
| 94 | private var stopGraceJob: Job? = null | |
| 95 | ||
| 96 | @Synchronized | |
| 97 | fun acquire(group: String): Long { | |
| 98 | val token = ++tokenSeq | |
| 99 | if (group != activeGroup) { | |
| 100 | // Group change: holders of other groups are obsolete. | |
| 101 | holders.entries.removeAll { it.value != group } | |
| 102 | pollJob?.cancel() | |
| 103 | _now.value = null | |
| 104 | activeGroup = group | |
| 105 | pollJob = scope.launch { pollLoop(group) } | |
| 106 | } else if (pollJob?.isActive != true) { | |
| 107 | // Same group, poller latched off or never started: re-arm, | |
| 108 | // PRESERVING any metadata already on screen. | |
| 109 | pollJob = scope.launch { pollLoop(group) } | |
| 110 | } | |
| 111 | holders[token] = group | |
| 112 | // A re-acquire within the stop grace cancels the pending teardown — | |
| 113 | // lifecycle blips (config changes, transient uiForeground flaps) | |
| 114 | // must not blank the screen. | |
| 115 | stopGraceJob?.cancel() | |
| 116 | stopGraceJob = null | |
| 117 | android.util.Log.i("EarhornRadio", | |
| 118 | "acquire(group='$group') -> token=$token holders=${holders.size} " + | |
| 119 | "jobActive=${pollJob?.isActive}") | |
| 120 | return token | |
| 121 | } | |
| 122 | ||
| 123 | @Synchronized | |
| 124 | fun release(token: Long) { | |
| 125 | val group = holders.remove(token) | |
| 126 | if (group == null) { | |
| 127 | android.util.Log.i("EarhornRadio", | |
| 128 | "release(token=$token): unknown/stale — ignored") | |
| 129 | return | |
| 130 | } | |
| 131 | val remaining = holders.count { it.value == activeGroup } | |
| 132 | android.util.Log.i("EarhornRadio", | |
| 133 | "release(token=$token, group='$group') remaining=$remaining") | |
| 134 | if (activeGroup != null && remaining == 0) { | |
| 135 | android.util.Log.i("EarhornRadio", | |
| 136 | "last holder released — stopping poller for '$activeGroup' " + | |
| 137 | "after ${STOP_GRACE_MS}ms grace unless re-acquired") | |
| 138 | stopGraceJob?.cancel() | |
| 139 | stopGraceJob = scope.launch { | |
| 140 | delay(STOP_GRACE_MS) | |
| 141 | @Suppress("SENSELESS_COMPARISON") | |
| 142 | synchronized(this@RadioStatusClient) { | |
| 143 | if (holders.none { it.value == activeGroup }) { | |
| 144 | android.util.Log.i("EarhornRadio", | |
| 145 | "grace expired — poller stopped for '$activeGroup'") | |
| 146 | pollJob?.cancel() | |
| 147 | pollJob = null | |
| 148 | activeGroup = null | |
| 149 | _now.value = null | |
| 150 | } | |
| 151 | } | |
| 152 | } | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | private suspend fun pollLoop(group: String) { | |
| 157 | // The memo notes the first roughly 20 s of a radio session returns | |
| 158 | // station:null because musicnow hasn't fetched yet. Latching off on | |
| 159 | // the very first null would therefore lose metadata for the whole | |
| 160 | // session whenever the screen attaches before that fetch lands — | |
| 161 | // the normal case, since the flip and the cast are simultaneous. | |
| 162 | // So: allow a few nulls at the start, then latch. A library session | |
| 163 | // costs NULL_READS_BEFORE_LATCH requests spread over ~36 s, not one, | |
| 164 | // which is still nothing next to other screens' standing poll. | |
| 165 | var nullReads = 0 | |
| 166 | var consecutiveFailures = 0 | |
| 167 | // Phase jitter: every screen in the group starts polling at the | |
| 168 | // same broadcast moment; without this the fleet's polls land on | |
| 169 | // musicnow as a synchronized spike every interval instead of a | |
| 170 | // spread. 0–3 s once per session desynchronizes the phases. | |
| 171 | delay(kotlin.random.Random.nextLong(0, 3_000)) | |
| 172 | while (scope.isActive) { | |
| 173 | when (val r = fetch(group)) { | |
| 174 | is Poll.Station -> { | |
| 175 | nullReads = 0 | |
| 176 | consecutiveFailures = 0 | |
| 177 | _now.value = r.now | |
| 178 | } | |
| 179 | Poll.NoStation -> { | |
| 180 | consecutiveFailures = 0 | |
| 181 | nullReads++ | |
| 182 | // DELIBERATELY not clearing yet: the fleet-wide | |
| 183 | // flash-then-default incident (2026-08-29 evening) | |
| 184 | // traced to wiping the display on the FIRST null while | |
| 185 | // the latch still counted. Displayed metadata survives | |
| 186 | // until the latch is certain. | |
| 187 | android.util.Log.i("EarhornRadio", | |
| 188 | "poll -> station null ($nullReads/$NULL_READS_BEFORE_LATCH) — display kept") | |
| 189 | if (nullReads >= NULL_READS_BEFORE_LATCH) { | |
| 190 | android.util.Log.i("EarhornRadio", "latched off for '$group'") | |
| 191 | _now.value = null | |
| 192 | return | |
| 193 | } | |
| 194 | } | |
| 195 | Poll.Failed -> { | |
| 196 | // Same incident, second half: fetch failures were | |
| 197 | // indistinguishable from NoStation, so every timed-out | |
| 198 | // poll under server contention blanked every screen. | |
| 199 | // Keep last-good, never latch on failures, retry. | |
| 200 | consecutiveFailures++ | |
| 201 | android.util.Log.w("EarhornRadio", | |
| 202 | "poll -> fetch failed ($consecutiveFailures consecutive) — " + | |
| 203 | "display kept, backing off") | |
| 204 | } | |
| 205 | } | |
| 206 | // Backoff doubles the interval per consecutive failure, capped | |
| 207 | // at 60 s: a struggling server must not receive the whole | |
| 208 | // fleet's retries at full cadence at its worst moment. One | |
| 209 | // success resets to the normal 12 s. | |
| 210 | val interval = if (consecutiveFailures == 0) POLL_INTERVAL_MS | |
| 211 | else (POLL_INTERVAL_MS shl consecutiveFailures.coerceAtMost(3)) | |
| 212 | .coerceAtMost(60_000L) | |
| 213 | delay(interval) | |
| 214 | } | |
| 215 | } | |
| 216 | ||
| 217 | /** One read. Returns null for every "behave exactly as today" case: | |
| 218 | * station null, non-200, unreachable, malformed body. */ | |
| 219 | private suspend fun fetch(group: String): Poll = withContext(Dispatchers.IO) { | |
| 220 | // No address configured: behave as "no station" so the poller latches off. | |
| 221 | if (STATUS_BASE_URL.isBlank()) return@withContext Poll.NoStation | |
| 222 | runCatching<Poll> { | |
| 223 | val encoded = URLEncoder.encode(group, "UTF-8") | |
| 224 | val conn = (URL("$STATUS_BASE_URL?group=$encoded") | |
| 225 | .openConnection() as HttpURLConnection).apply { | |
| 226 | connectTimeout = 4_000 | |
| 227 | readTimeout = 4_000 | |
| 228 | requestMethod = "GET" | |
| 229 | } | |
| 230 | try { | |
| 231 | if (conn.responseCode != 200) { | |
| 232 | android.util.Log.w("EarhornRadio", | |
| 233 | "GET $STATUS_BASE_URL?group=$encoded -> HTTP ${conn.responseCode}") | |
| 234 | return@runCatching Poll.Failed | |
| 235 | } | |
| 236 | val json = JSONObject(conn.inputStream.bufferedReader().readText()) | |
| 237 | // MUST use optStringOrNull, never optString: org.json's | |
| 238 | // optString returns the LITERAL string "null" for a JSON | |
| 239 | // null value. /status sends "station": null on every | |
| 240 | // library session, which optString turned into a non-blank | |
| 241 | // "null" — so library playback was treated as radio (album | |
| 242 | // line rendered "null") and the latch never tripped, so it | |
| 243 | // polled musicnow forever. Both are contract violations. | |
| 244 | val station = json.optStringOrNull("station") | |
| 245 | ?: return@runCatching Poll.NoStation | |
| 246 | // Station metadata (song, composer, programme) comes from | |
| 247 | // musicnow's /status only — it fetches each station's feed | |
| 248 | // centrally on the server (design decision 2026-09-02). No screen | |
| 249 | // contacts any host but the server for it. | |
| 250 | Poll.Station(RadioNow( | |
| 251 | station = station, | |
| 252 | title = json.optStringOrNull("title"), | |
| 253 | artist = json.optStringOrNull("artist"), | |
| 254 | // musicnow HEAD-checks art before serving it, so a | |
| 255 | // non-null value is a real image; null is common and | |
| 256 | // expected — the UI draws its own placeholder. | |
| 257 | albumArtUrl = json.optStringOrNull("album_art"), | |
| 258 | )) | |
| 259 | } finally { | |
| 260 | conn.disconnect() | |
| 261 | } | |
| 262 | }.onFailure { | |
| 263 | android.util.Log.w("EarhornRadio", | |
| 264 | "fetch failed for group='$group': ${it.javaClass.simpleName}: ${it.message}") | |
| 265 | }.getOrDefault(Poll.Failed) | |
| 266 | } | |
| 267 | ||
| 268 | /** One read of /status for the LIBRARY track musicnow is playing on | |
| 269 | * [group] — the fields review added 2026-09-02 (year, disc_number, | |
| 270 | * track_number, genre, composer, album_artist, sample_rate, bit_depth, | |
| 271 | * bitrate, channels, size, mime, library_id, parent_id, album_art_uri). | |
| 272 | * Returns null for a station, for a track musicnow didn't load itself | |
| 273 | * (all fields null), or on any error. Used by the observer on | |
| 274 | * automation screens instead of a UPnP Search, so those screens need | |
| 275 | * no UPnP engine at all (design decision 2026-09-02). */ | |
| 276 | suspend fun fetchLibraryTrack(group: String): dev.stave.domain.model.DidlItem.Track? = | |
| 277 | withContext(Dispatchers.IO) { | |
| 278 | if (STATUS_BASE_URL.isBlank()) return@withContext null | |
| 279 | runCatching { | |
| 280 | val encoded = URLEncoder.encode(group, "UTF-8") | |
| 281 | val conn = (URL("$STATUS_BASE_URL?group=$encoded") | |
| 282 | .openConnection() as HttpURLConnection).apply { | |
| 283 | connectTimeout = 4_000 | |
| 284 | readTimeout = 4_000 | |
| 285 | requestMethod = "GET" | |
| 286 | } | |
| 287 | try { | |
| 288 | if (conn.responseCode != 200) return@runCatching null | |
| 289 | val json = JSONObject(conn.inputStream.bufferedReader().readText()) | |
| 290 | if (json.optStringOrNull("station") != null) return@runCatching null | |
| 291 | val title = json.optStringOrNull("title") ?: return@runCatching null | |
| 292 | val libraryId = json.optStringOrNull("library_id") | |
| 293 | fun optIntOrNull(k: String): Int? = if (json.isNull(k)) null else json.optInt(k).takeIf { it != 0 } | |
| 294 | fun optLongOrNull(k: String): Long? = if (json.isNull(k)) null else json.optLong(k).takeIf { it != 0L } | |
| 295 | dev.stave.domain.model.DidlItem.Track( | |
| 296 | id = libraryId ?: "", | |
| 297 | parentId = json.optStringOrNull("parent_id") ?: "", | |
| 298 | title = title, | |
| 299 | artist = json.optStringOrNull("artist"), | |
| 300 | albumArtist = json.optStringOrNull("album_artist"), | |
| 301 | album = json.optStringOrNull("album"), | |
| 302 | composer = json.optStringOrNull("composer"), | |
| 303 | genre = json.optStringOrNull("genre"), | |
| 304 | date = json.optStringOrNull("year"), | |
| 305 | trackNumber = optIntOrNull("track_number"), | |
| 306 | discNumber = optIntOrNull("disc_number"), | |
| 307 | duration = null, | |
| 308 | albumArtUri = json.optStringOrNull("album_art_uri") ?: json.optStringOrNull("album_art"), | |
| 309 | resourceUrl = "", | |
| 310 | mimeType = json.optStringOrNull("mime"), | |
| 311 | sampleFrequency = optIntOrNull("sample_rate"), | |
| 312 | bitsPerSample = optIntOrNull("bit_depth"), | |
| 313 | channels = optIntOrNull("channels"), | |
| 314 | bitrate = optIntOrNull("bitrate"), | |
| 315 | size = optLongOrNull("size"), | |
| 316 | ) | |
| 317 | } finally { | |
| 318 | conn.disconnect() | |
| 319 | } | |
| 320 | }.onFailure { | |
| 321 | android.util.Log.w("EarhornRadio", | |
| 322 | "library fetch failed for group='$group': ${it.javaClass.simpleName}: ${it.message}") | |
| 323 | }.getOrNull() | |
| 324 | } | |
| 325 | ||
| 326 | /** JSON null-safe string read. [JSONObject.optString] returns the | |
| 327 | * literal "null" for a JSON null, which is a non-blank string and | |
| 328 | * therefore passes every emptiness check — the source of the library | |
| 329 | * sessions being misread as radio. Always use this. */ | |
| 330 | private fun JSONObject.optStringOrNull(key: String): String? = | |
| 331 | if (isNull(key)) null else optString(key).trim().ifBlank { null } | |
| 332 | ||
| 333 | companion object { | |
| 334 | /** musicnow's /status address on your LAN, from `earhorn.musicnowStatusUrl` | |
| 335 | * in earhorn-private.properties at build time. Empty = station overlay off. */ | |
| 336 | val STATUS_BASE_URL: String = dev.stave.BuildConfig.MUSICNOW_STATUS_URL | |
| 337 | ||
| 338 | ||
| 339 | /** Within the memo's suggested 10–15 s. musicnow serves one request | |
| 340 | * at a time and other screens already poll it — do not shorten. */ | |
| 341 | const val POLL_INTERVAL_MS = 12_000L | |
| 342 | ||
| 343 | const val NO_TOKEN = -1L | |
| 344 | ||
| 345 | /** Grace between the last release and the actual poller stop, so a | |
| 346 | * release-then-reacquire blink (recomposition, config change, | |
| 347 | * momentary background) never blanks the screen. */ | |
| 348 | const val STOP_GRACE_MS = 5_000L | |
| 349 | ||
| 350 | /** Consecutive station:null reads tolerated before latching off. | |
| 351 | * Covers musicnow's ~20 s pre-fetch window at the start of a radio | |
| 352 | * session (3 × 12 s = 36 s) without polling forever on library | |
| 353 | * sessions. The caller re-arms on any session content change. */ | |
| 354 | const val NULL_READS_BEFORE_LATCH = 3 | |
| 355 | } | |
| 356 | } |


