feat: add HTTP stream connection with ICY header support

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 02:13:50 -04:00
parent fd73caf181
commit 7814d682f6
2 changed files with 179 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
package xyz.cottongin.radio247.audio
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import java.io.InputStream
import java.time.Duration
class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause)
class StreamConnection(private val url: String) {
private val client = OkHttpClient.Builder()
.readTimeout(Duration.ofSeconds(30))
.build()
var metaint: Int? = null
private set
var inputStream: InputStream? = null
private set
private var response: Response? = null
fun open() {
val request = Request.Builder()
.url(url)
.header("Icy-MetaData", "1")
.header("User-Agent", "Radio247/1.0")
.build()
try {
val resp = client.newCall(request).execute()
if (!resp.isSuccessful) {
resp.close()
throw ConnectionFailed("HTTP ${resp.code}")
}
response = resp
metaint = resp.header("icy-metaint")?.toIntOrNull()
inputStream = resp.body?.byteStream()
?: throw ConnectionFailed("Empty response body")
} catch (e: IOException) {
throw ConnectionFailed("Network error", e)
}
}
fun close() {
try {
inputStream?.close()
} catch (_: IOException) {}
try {
response?.close()
} catch (_: IOException) {}
response = null
inputStream = null
metaint = null
}
}

View File

@@ -0,0 +1,123 @@
package xyz.cottongin.radio247.audio
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class StreamConnectionTest {
private val server = MockWebServer()
@After
fun tearDown() {
server.shutdown()
}
@Test
fun sends_icy_metadata_header_and_reads_metaint_from_response() {
server.enqueue(
MockResponse()
.setHeader("icy-metaint", "16000")
.setHeader("Content-Type", "audio/mpeg")
.setBody("fake audio data")
)
server.start()
val conn = StreamConnection(server.url("/stream").toString())
conn.open()
val request = server.takeRequest()
assertEquals("1", request.getHeader("Icy-MetaData"))
assertEquals(16000, conn.metaint)
assertNotNull(conn.inputStream)
conn.close()
}
@Test
fun metaint_is_null_when_server_does_not_provide_it() {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "audio/mpeg")
.setBody("fake audio data")
)
server.start()
val conn = StreamConnection(server.url("/stream").toString())
conn.open()
assertNull(conn.metaint)
assertNotNull(conn.inputStream)
conn.close()
}
@Test(expected = ConnectionFailed::class)
fun throws_ConnectionFailed_on_HTTP_error() {
server.enqueue(MockResponse().setResponseCode(404))
server.start()
val conn = StreamConnection(server.url("/stream").toString())
conn.open()
}
@Test
fun throws_ConnectionFailed_on_network_error() {
server.start()
val url = server.url("/stream").toString()
server.shutdown()
try {
val conn = StreamConnection(url)
conn.open()
fail("Expected ConnectionFailed")
} catch (e: ConnectionFailed) {
assertTrue(e.message!!.contains("Network error"))
assertNotNull(e.cause)
}
}
@Test
fun close_cleans_up_resources() {
server.enqueue(
MockResponse()
.setHeader("icy-metaint", "16000")
.setBody("fake audio data")
)
server.start()
val conn = StreamConnection(server.url("/stream").toString())
conn.open()
assertNotNull(conn.inputStream)
assertNotNull(conn.metaint)
conn.close()
assertNull(conn.inputStream)
assertNull(conn.metaint)
}
@Test
fun sends_user_agent_header() {
server.enqueue(
MockResponse()
.setHeader("icy-metaint", "16000")
.setBody("fake audio data")
)
server.start()
val conn = StreamConnection(server.url("/stream").toString())
conn.open()
val request = server.takeRequest()
assertEquals("Radio247/1.0", request.getHeader("User-Agent"))
conn.close()
}
}