feat: add HTTP stream connection with ICY header support
Made-with: Cursor
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user