package dev.stave.cast.radio
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
/**
* One read of musicnow's `/status` for a speaker group (ported from Android,
* design decision, 2026-09-04): when the group plays a radio station,
* musicnow knows the song, artist and art the station is broadcasting and
* serves them here. The desktop shows them over the station's name. The
* address is a setting (`SettingsStore.musicnowStatusUrl`); blank = off.
*
* The result distinguishes the three cases Android learned to keep apart
* (fleet-wide flash-then-default incident, 2026-08-29):
* - [Poll.Station] — radio with real metadata.
* - [Poll.NoStation] — HTTP 200 with `station: null`: a library session (or
* a station that publishes nothing). Counts toward the
* caller's latch.
* - [Poll.Failed] — timeout, non-200, malformed body. Says NOTHING about
* the session: the caller must keep the last good
* overlay on screen and back off, never blank it.
* - [Poll.Disabled] — no address configured.
*
* Polling cadence/latching lives in the caller (RawCastObserver), which owns
* the session lifetime. musicnow's HTTP server handles one request at a time
* and other clients already poll it — callers must not shorten the
* interval below [POLL_INTERVAL_MS].
*/
class RadioStatusClient(private val settings: dev.stave.data.SettingsStore) {
data class RadioNow(val station: String, val title: String?, val artist: String?, val albumArtUrl: String?)
sealed interface Poll {
data class Station(val now: RadioNow) : Poll
data object NoStation : Poll
data object Failed : Poll
data object Disabled : Poll
}
suspend fun poll(group: String): Poll = withContext(Dispatchers.IO) {
val base = settings.musicnowStatusUrl.value.trim()
if (base.isBlank()) return@withContext Poll.Disabled
try {
val conn = (URL("$base?group=${URLEncoder.encode(group, "UTF-8")}").openConnection() as HttpURLConnection)
.apply { connectTimeout = 4_000; readTimeout = 4_000; requestMethod = "GET" }
try {
if (conn.responseCode != 200) {
System.err.println("[EarhornRadio] GET /status for '$group' -> HTTP ${conn.responseCode}")
return@withContext Poll.Failed
}
val json = JSONObject(conn.inputStream.bufferedReader().use { it.readText() })
// Never optString: it returns the literal "null" for a JSON
// null, which turned every library session into "radio".
fun str(k: String): String? = if (json.isNull(k)) null else json.optString(k).trim().ifBlank { null }
val station = str("station") ?: return@withContext Poll.NoStation
Poll.Station(RadioNow(station, str("title"), str("artist"), str("album_art")))
} finally {
conn.disconnect()
}
} catch (e: java.io.IOException) {
System.err.println("[EarhornRadio] /status for '$group' failed: ${e.javaClass.simpleName}: ${e.message}")
Poll.Failed
} catch (e: org.json.JSONException) {
System.err.println("[EarhornRadio] /status for '$group' returned malformed JSON: ${e.message}")
Poll.Failed
} catch (e: IllegalArgumentException) {
// Malformed URL in settings.
System.err.println("[EarhornRadio] musicnow address is not a valid URL: ${e.message}")
Poll.Failed
}
}
/** Back-compat single read: the overlay or null. Prefer [poll]. */
suspend fun fetch(group: String): RadioNow? = (poll(group) as? Poll.Station)?.now
companion object {
/** Within the memo's 10–15 s. Do not shorten (see class KDoc). */
const val POLL_INTERVAL_MS = 12_000L
/** Consecutive station:null reads tolerated before latching off —
* covers musicnow's ~20 s pre-fetch window at the start of a radio
* session (3 × 12 s) without polling forever on library sessions. */
const val NULL_READS_BEFORE_LATCH = 3
/** Failure backoff ceiling. */
const val MAX_BACKOFF_MS = 60_000L
}
}