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

RadioStatusClient.kt

356 lines · 17.9 KB

Changed in: 0.5.0

1package dev.stave.cast.radio
2
3import kotlinx.coroutines.CoroutineScope
4import kotlinx.coroutines.Dispatchers
5import kotlinx.coroutines.Job
6import kotlinx.coroutines.SupervisorJob
7import kotlinx.coroutines.delay
8import kotlinx.coroutines.flow.MutableStateFlow
9import kotlinx.coroutines.flow.StateFlow
10import kotlinx.coroutines.flow.asStateFlow
11import kotlinx.coroutines.isActive
12import kotlinx.coroutines.launch
13import kotlinx.coroutines.withContext
14import org.json.JSONObject
15import java.net.HttpURLConnection
16import java.net.URL
17import java.net.URLEncoder
18import javax.inject.Inject
19import 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
50class 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}