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

RadioStatusClient.kt

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
    }
}