RadioStatusClient.kt
356 lines · 17.9 KB
Changed in: 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 | } |


