package dev.stave.cast.radio
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import javax.inject.Inject
import javax.inject.Singleton
/**
* Live song metadata for internet-radio sessions, read from musicnow's
* existing `/status` endpoint (voice memo 2026-08-27).
*
* Radio sessions carry no song in the Cast media session — musicnow gives
* the receiver a stream URL and a static station title, and the receiver
* never learns the song. musicnow reads it station-side (one fetch per
* playing station for the whole house) and publishes it on `/status`. This
* client reads that field. It opens NO connection to any Cast device, does
* no ICY, and fetches nothing from stations.
*
* `station` is the whole decision, per the memo:
* - non-null → radio with real metadata; title/artist/album_art are the
* song's, and polling continues.
* - null → library playback, or radio from a station that publishes
* nothing. The Cast session is authoritative; this client
* emits null and STOPS POLLING for the session.
* - any failure (unreachable, non-200, malformed) → emits null, stops.
* Never an error state on a fixed screen.
*
* Polling discipline is a hard constraint, not a preference: musicnow's
* HTTP server handles one request at a time and other screens already
* poll it. Callers must [start] only while Now Playing is actually on
* screen and [stop] on leaving it, pause of the observation, or hand-back.
* Consecutive `station == null` reads latch this client off until the next
* [start] (the caller re-arms on session content change), so a library
* session costs a few requests at attach and nothing thereafter.
*/
@Singleton
class RadioStatusClient @Inject constructor() {
/** One now-playing snapshot from /status, only ever populated when
* `station` was non-null. [album] from /status is deliberately NOT
* carried: it arrives verbatim from the station and is often the
* literal "Unknown Album" (server-side filtering declined by the
* maintainer), and Now Playing shows the station on that line instead. */
data class RadioNow(
val station: String,
val title: String?,
val artist: String?,
val albumArtUrl: String?,
)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _now = MutableStateFlow<RadioNow?>(null)
val now: StateFlow<RadioNow?> = _now.asStateFlow()
private var pollJob: Job? = null
private var activeGroup: String? = null
private sealed interface Poll {
data class Station(val now: RadioNow) : Poll
/** 200 with station:null — a genuine library session; counts
* toward the latch. */
data object NoStation : Poll
/** Timeout, non-200, or exception — says nothing about the
* session. Never latches, never clears the display. */
data object Failed : Poll
}
/** Interest refcount. Single-owner tokens FAILED in the field
* (2026-08-29 17:11:46.248): during a nav-transition overlap the
* DYING composition's gate re-ran on the contentKey change, hit the
* idempotent fast-path, and was handed the current token — so its
* teardown carried a valid credential and wiped live metadata one
* second after paint. With two legitimate owners and unspecified
* teardown order, ownership is the wrong model. Compositions now
* ACQUIRE interest and RELEASE it; polling runs while any live
* holder exists for the active group, so the outgoing composition's
* release is harmless while the incoming one still holds. */
private var tokenSeq = 0L
private val holders = mutableMapOf<Long, String>()
private var stopGraceJob: Job? = null
@Synchronized
fun acquire(group: String): Long {
val token = ++tokenSeq
if (group != activeGroup) {
// Group change: holders of other groups are obsolete.
holders.entries.removeAll { it.value != group }
pollJob?.cancel()
_now.value = null
activeGroup = group
pollJob = scope.launch { pollLoop(group) }
} else if (pollJob?.isActive != true) {
// Same group, poller latched off or never started: re-arm,
// PRESERVING any metadata already on screen.
pollJob = scope.launch { pollLoop(group) }
}
holders[token] = group
// A re-acquire within the stop grace cancels the pending teardown —
// lifecycle blips (config changes, transient uiForeground flaps)
// must not blank the screen.
stopGraceJob?.cancel()
stopGraceJob = null
android.util.Log.i("EarhornRadio",
"acquire(group='$group') -> token=$token holders=${holders.size} " +
"jobActive=${pollJob?.isActive}")
return token
}
@Synchronized
fun release(token: Long) {
val group = holders.remove(token)
if (group == null) {
android.util.Log.i("EarhornRadio",
"release(token=$token): unknown/stale — ignored")
return
}
val remaining = holders.count { it.value == activeGroup }
android.util.Log.i("EarhornRadio",
"release(token=$token, group='$group') remaining=$remaining")
if (activeGroup != null && remaining == 0) {
android.util.Log.i("EarhornRadio",
"last holder released — stopping poller for '$activeGroup' " +
"after ${STOP_GRACE_MS}ms grace unless re-acquired")
stopGraceJob?.cancel()
stopGraceJob = scope.launch {
delay(STOP_GRACE_MS)
@Suppress("SENSELESS_COMPARISON")
synchronized(this@RadioStatusClient) {
if (holders.none { it.value == activeGroup }) {
android.util.Log.i("EarhornRadio",
"grace expired — poller stopped for '$activeGroup'")
pollJob?.cancel()
pollJob = null
activeGroup = null
_now.value = null
}
}
}
}
}
private suspend fun pollLoop(group: String) {
// The memo notes the first roughly 20 s of a radio session returns
// station:null because musicnow hasn't fetched yet. Latching off on
// the very first null would therefore lose metadata for the whole
// session whenever the screen attaches before that fetch lands —
// the normal case, since the flip and the cast are simultaneous.
// So: allow a few nulls at the start, then latch. A library session
// costs NULL_READS_BEFORE_LATCH requests spread over ~36 s, not one,
// which is still nothing next to other screens' standing poll.
var nullReads = 0
var consecutiveFailures = 0
// Phase jitter: every screen in the group starts polling at the
// same broadcast moment; without this the fleet's polls land on
// musicnow as a synchronized spike every interval instead of a
// spread. 0–3 s once per session desynchronizes the phases.
delay(kotlin.random.Random.nextLong(0, 3_000))
while (scope.isActive) {
when (val r = fetch(group)) {
is Poll.Station -> {
nullReads = 0
consecutiveFailures = 0
_now.value = r.now
}
Poll.NoStation -> {
consecutiveFailures = 0
nullReads++
// DELIBERATELY not clearing yet: the fleet-wide
// flash-then-default incident (2026-08-29 evening)
// traced to wiping the display on the FIRST null while
// the latch still counted. Displayed metadata survives
// until the latch is certain.
android.util.Log.i("EarhornRadio",
"poll -> station null ($nullReads/$NULL_READS_BEFORE_LATCH) — display kept")
if (nullReads >= NULL_READS_BEFORE_LATCH) {
android.util.Log.i("EarhornRadio", "latched off for '$group'")
_now.value = null
return
}
}
Poll.Failed -> {
// Same incident, second half: fetch failures were
// indistinguishable from NoStation, so every timed-out
// poll under server contention blanked every screen.
// Keep last-good, never latch on failures, retry.
consecutiveFailures++
android.util.Log.w("EarhornRadio",
"poll -> fetch failed ($consecutiveFailures consecutive) — " +
"display kept, backing off")
}
}
// Backoff doubles the interval per consecutive failure, capped
// at 60 s: a struggling server must not receive the whole
// fleet's retries at full cadence at its worst moment. One
// success resets to the normal 12 s.
val interval = if (consecutiveFailures == 0) POLL_INTERVAL_MS
else (POLL_INTERVAL_MS shl consecutiveFailures.coerceAtMost(3))
.coerceAtMost(60_000L)
delay(interval)
}
}
/** One read. Returns null for every "behave exactly as today" case:
* station null, non-200, unreachable, malformed body. */
private suspend fun fetch(group: String): Poll = withContext(Dispatchers.IO) {
// No address configured: behave as "no station" so the poller latches off.
if (STATUS_BASE_URL.isBlank()) return@withContext Poll.NoStation
runCatching<Poll> {
val encoded = URLEncoder.encode(group, "UTF-8")
val conn = (URL("$STATUS_BASE_URL?group=$encoded")
.openConnection() as HttpURLConnection).apply {
connectTimeout = 4_000
readTimeout = 4_000
requestMethod = "GET"
}
try {
if (conn.responseCode != 200) {
android.util.Log.w("EarhornRadio",
"GET $STATUS_BASE_URL?group=$encoded -> HTTP ${conn.responseCode}")
return@runCatching Poll.Failed
}
val json = JSONObject(conn.inputStream.bufferedReader().readText())
// MUST use optStringOrNull, never optString: org.json's
// optString returns the LITERAL string "null" for a JSON
// null value. /status sends "station": null on every
// library session, which optString turned into a non-blank
// "null" — so library playback was treated as radio (album
// line rendered "null") and the latch never tripped, so it
// polled musicnow forever. Both are contract violations.
val station = json.optStringOrNull("station")
?: return@runCatching Poll.NoStation
// Station metadata (song, composer, programme) comes from
// musicnow's /status only — it fetches each station's feed
// centrally on the server (design decision 2026-09-02). No screen
// contacts any host but the server for it.
Poll.Station(RadioNow(
station = station,
title = json.optStringOrNull("title"),
artist = json.optStringOrNull("artist"),
// musicnow HEAD-checks art before serving it, so a
// non-null value is a real image; null is common and
// expected — the UI draws its own placeholder.
albumArtUrl = json.optStringOrNull("album_art"),
))
} finally {
conn.disconnect()
}
}.onFailure {
android.util.Log.w("EarhornRadio",
"fetch failed for group='$group': ${it.javaClass.simpleName}: ${it.message}")
}.getOrDefault(Poll.Failed)
}
/** One read of /status for the LIBRARY track musicnow is playing on
* [group] — the fields review added 2026-09-02 (year, disc_number,
* track_number, genre, composer, album_artist, sample_rate, bit_depth,
* bitrate, channels, size, mime, library_id, parent_id, album_art_uri).
* Returns null for a station, for a track musicnow didn't load itself
* (all fields null), or on any error. Used by the observer on
* automation screens instead of a UPnP Search, so those screens need
* no UPnP engine at all (design decision 2026-09-02). */
suspend fun fetchLibraryTrack(group: String): dev.stave.domain.model.DidlItem.Track? =
withContext(Dispatchers.IO) {
if (STATUS_BASE_URL.isBlank()) return@withContext null
runCatching {
val encoded = URLEncoder.encode(group, "UTF-8")
val conn = (URL("$STATUS_BASE_URL?group=$encoded")
.openConnection() as HttpURLConnection).apply {
connectTimeout = 4_000
readTimeout = 4_000
requestMethod = "GET"
}
try {
if (conn.responseCode != 200) return@runCatching null
val json = JSONObject(conn.inputStream.bufferedReader().readText())
if (json.optStringOrNull("station") != null) return@runCatching null
val title = json.optStringOrNull("title") ?: return@runCatching null
val libraryId = json.optStringOrNull("library_id")
fun optIntOrNull(k: String): Int? = if (json.isNull(k)) null else json.optInt(k).takeIf { it != 0 }
fun optLongOrNull(k: String): Long? = if (json.isNull(k)) null else json.optLong(k).takeIf { it != 0L }
dev.stave.domain.model.DidlItem.Track(
id = libraryId ?: "",
parentId = json.optStringOrNull("parent_id") ?: "",
title = title,
artist = json.optStringOrNull("artist"),
albumArtist = json.optStringOrNull("album_artist"),
album = json.optStringOrNull("album"),
composer = json.optStringOrNull("composer"),
genre = json.optStringOrNull("genre"),
date = json.optStringOrNull("year"),
trackNumber = optIntOrNull("track_number"),
discNumber = optIntOrNull("disc_number"),
duration = null,
albumArtUri = json.optStringOrNull("album_art_uri") ?: json.optStringOrNull("album_art"),
resourceUrl = "",
mimeType = json.optStringOrNull("mime"),
sampleFrequency = optIntOrNull("sample_rate"),
bitsPerSample = optIntOrNull("bit_depth"),
channels = optIntOrNull("channels"),
bitrate = optIntOrNull("bitrate"),
size = optLongOrNull("size"),
)
} finally {
conn.disconnect()
}
}.onFailure {
android.util.Log.w("EarhornRadio",
"library fetch failed for group='$group': ${it.javaClass.simpleName}: ${it.message}")
}.getOrNull()
}
/** JSON null-safe string read. [JSONObject.optString] returns the
* literal "null" for a JSON null, which is a non-blank string and
* therefore passes every emptiness check — the source of the library
* sessions being misread as radio. Always use this. */
private fun JSONObject.optStringOrNull(key: String): String? =
if (isNull(key)) null else optString(key).trim().ifBlank { null }
companion object {
/** musicnow's /status address on your LAN, from `earhorn.musicnowStatusUrl`
* in earhorn-private.properties at build time. Empty = station overlay off. */
val STATUS_BASE_URL: String = dev.stave.BuildConfig.MUSICNOW_STATUS_URL
/** Within the memo's suggested 10–15 s. musicnow serves one request
* at a time and other screens already poll it — do not shorten. */
const val POLL_INTERVAL_MS = 12_000L
const val NO_TOKEN = -1L
/** Grace between the last release and the actual poller stop, so a
* release-then-reacquire blink (recomposition, config change,
* momentary background) never blanks the screen. */
const val STOP_GRACE_MS = 5_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 = 36 s) without polling forever on library
* sessions. The caller re-arms on any session content change. */
const val NULL_READS_BEFORE_LATCH = 3
}
}