feat: add Shoutcast v1 stream support via raw socket fallback

Shoutcast v1 servers respond with `ICY 200 OK` instead of valid HTTP,
which OkHttp rejects. StreamConnection now falls back to a raw socket
that manually parses ICY headers when OkHttp fails with a protocol error.
No downstream changes needed — IcyParser and the audio pipeline already
handle the identical ICY metadata format.

Made-with: Cursor
This commit is contained in:
cottongin
2026-04-27 21:47:54 -04:00
parent baf2bea3cf
commit 54aa8ad43a
2 changed files with 234 additions and 12 deletions

View File

@@ -1,11 +1,15 @@
package xyz.cottongin.radio247.audio
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import java.io.BufferedInputStream
import java.io.IOException
import java.io.InputStream
import java.net.Socket
import java.net.URL
import java.time.Duration
class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause)
@@ -31,31 +35,81 @@ class StreamConnection(private val url: String) {
var streamInfo: StreamInfo? = null
private set
private var response: Response? = null
private var rawSocket: Socket? = null
fun open() {
try {
openWithOkHttp()
} catch (e: IOException) {
if (looksLikeIcyProtocolError(e)) {
Log.i(TAG, "OkHttp rejected response (likely ICY protocol), falling back to raw socket")
openWithRawSocket()
} else {
throw ConnectionFailed("Network error", e)
}
}
}
private fun openWithOkHttp() {
val request = Request.Builder()
.url(url)
.header("Icy-MetaData", "1")
.header("User-Agent", "Radio247/1.0")
.build()
val resp = client.newCall(request).execute()
if (!resp.isSuccessful) {
resp.close()
throw ConnectionFailed("HTTP ${resp.code}")
}
response = resp
metaint = resp.header("icy-metaint")?.toIntOrNull()
streamInfo = StreamInfo(
bitrate = resp.header("icy-br")?.toIntOrNull(),
ssl = url.startsWith("https", ignoreCase = true),
contentType = resp.header("Content-Type")
)
inputStream = resp.body?.byteStream()
?: throw ConnectionFailed("Empty response body")
}
private fun openWithRawSocket() {
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()
val parsed = URL(url)
val host = parsed.host ?: throw ConnectionFailed("No host in URL")
val port = if (parsed.port != -1) parsed.port else parsed.defaultPort
val path = if (parsed.path.isNullOrEmpty()) "/" else parsed.path
val socket = Socket(host, port)
socket.soTimeout = 30_000
rawSocket = socket
val out = socket.getOutputStream()
val requestStr = "GET $path HTTP/1.0\r\n" +
"Host: $host\r\n" +
"User-Agent: Radio247/1.0\r\n" +
"Icy-MetaData: 1\r\n" +
"Accept: */*\r\n" +
"\r\n"
out.write(requestStr.toByteArray(Charsets.US_ASCII))
out.flush()
val buffered = BufferedInputStream(socket.getInputStream(), 16384)
val headers = parseIcyHeaders(buffered)
metaint = headers["icy-metaint"]?.toIntOrNull()
streamInfo = StreamInfo(
bitrate = resp.header("icy-br")?.toIntOrNull(),
bitrate = headers["icy-br"]?.toIntOrNull(),
ssl = url.startsWith("https", ignoreCase = true),
contentType = resp.header("Content-Type")
contentType = headers["content-type"]
)
inputStream = resp.body?.byteStream()
?: throw ConnectionFailed("Empty response body")
inputStream = buffered
} catch (e: ConnectionFailed) {
closeRawSocket()
throw e
} catch (e: IOException) {
throw ConnectionFailed("Network error", e)
closeRawSocket()
throw ConnectionFailed("Network error (raw socket)", e)
}
}
@@ -66,9 +120,74 @@ class StreamConnection(private val url: String) {
try {
response?.close()
} catch (_: IOException) {}
closeRawSocket()
response = null
inputStream = null
metaint = null
streamInfo = null
}
private fun closeRawSocket() {
try {
rawSocket?.close()
} catch (_: IOException) {}
rawSocket = null
}
companion object {
private const val TAG = "StreamConnection"
/**
* Shoutcast v1 servers respond with `ICY 200 OK` instead of a valid HTTP
* status line. OkHttp surfaces this as a ProtocolException.
*/
internal fun looksLikeIcyProtocolError(e: IOException): Boolean {
val msg = e.message ?: return false
return msg.contains("unexpected status line", ignoreCase = true) ||
msg.contains("http/0.9", ignoreCase = true) ||
e.javaClass.simpleName == "ProtocolException"
}
/**
* Reads headers line-by-line from an ICY response until the blank line.
* Returns a map of lowercased header names to values. Throws
* [ConnectionFailed] if the status line is not `ICY 200 OK` or `HTTP/1.x 200`.
*/
internal fun parseIcyHeaders(input: InputStream): Map<String, String> {
val statusLine = readLine(input)
if (!statusLine.startsWith("ICY ", ignoreCase = true) &&
!statusLine.startsWith("HTTP/", ignoreCase = true)
) {
throw ConnectionFailed("Unexpected status line: $statusLine")
}
val headers = mutableMapOf<String, String>()
while (true) {
val line = readLine(input)
if (line.isEmpty()) break
val colon = line.indexOf(':')
if (colon > 0) {
headers[line.substring(0, colon).trim().lowercase()] =
line.substring(colon + 1).trim()
}
}
return headers
}
private fun readLine(input: InputStream): String {
val sb = StringBuilder(128)
while (true) {
val b = input.read()
if (b == -1) break
if (b == '\n'.code) break
if (b == '\r'.code) {
input.mark(1)
if (input.read() != '\n'.code) input.reset()
break
}
sb.append(b.toChar())
}
return sb.toString()
}
}
}

View File

@@ -9,6 +9,8 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import java.io.ByteArrayInputStream
import java.net.ProtocolException
class StreamConnectionTest {
@@ -120,4 +122,105 @@ class StreamConnectionTest {
conn.close()
}
// --- ICY protocol header parsing (Shoutcast v1 support) ---
@Test
fun parseIcyHeaders_extracts_standard_shoutcast_headers() {
val raw = "ICY 200 OK\r\n" +
"icy-notice1:<BR>This stream requires Winamp<BR>\r\n" +
"icy-name:My Station\r\n" +
"icy-genre:Rock\r\n" +
"content-type:audio/mpeg\r\n" +
"icy-pub:1\r\n" +
"icy-metaint:8192\r\n" +
"icy-br:128\r\n" +
"\r\n" +
"audio bytes here"
val input = ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII))
val headers = StreamConnection.parseIcyHeaders(input)
assertEquals("8192", headers["icy-metaint"])
assertEquals("128", headers["icy-br"])
assertEquals("audio/mpeg", headers["content-type"])
assertEquals("My Station", headers["icy-name"])
assertEquals("Rock", headers["icy-genre"])
val remaining = input.readBytes().toString(Charsets.US_ASCII)
assertEquals("audio bytes here", remaining)
}
@Test
fun parseIcyHeaders_accepts_http_status_line() {
val raw = "HTTP/1.0 200 OK\r\n" +
"icy-metaint:16000\r\n" +
"Content-Type:audio/mpeg\r\n" +
"\r\n"
val headers = StreamConnection.parseIcyHeaders(
ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII))
)
assertEquals("16000", headers["icy-metaint"])
assertEquals("audio/mpeg", headers["content-type"])
}
@Test(expected = ConnectionFailed::class)
fun parseIcyHeaders_rejects_garbage_status_line() {
val raw = "GARBAGE\r\n\r\n"
StreamConnection.parseIcyHeaders(
ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII))
)
}
@Test
fun parseIcyHeaders_handles_missing_metaint() {
val raw = "ICY 200 OK\r\n" +
"content-type:audio/mpeg\r\n" +
"icy-br:64\r\n" +
"\r\n"
val headers = StreamConnection.parseIcyHeaders(
ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII))
)
assertNull(headers["icy-metaint"])
assertEquals("64", headers["icy-br"])
}
@Test
fun parseIcyHeaders_handles_lf_only_line_endings() {
val raw = "ICY 200 OK\n" +
"icy-metaint:4096\n" +
"content-type:audio/mpeg\n" +
"\n"
val headers = StreamConnection.parseIcyHeaders(
ByteArrayInputStream(raw.toByteArray(Charsets.US_ASCII))
)
assertEquals("4096", headers["icy-metaint"])
}
@Test
fun looksLikeIcyProtocolError_matches_protocol_exception() {
assertTrue(
StreamConnection.looksLikeIcyProtocolError(
ProtocolException("unexpected status line: ICY 200 OK")
)
)
}
@Test
fun looksLikeIcyProtocolError_matches_http09_message() {
assertTrue(
StreamConnection.looksLikeIcyProtocolError(
java.io.IOException("Received HTTP/0.9 when not allowed")
)
)
}
@Test
fun looksLikeIcyProtocolError_does_not_match_unrelated_io_error() {
assertTrue(
!StreamConnection.looksLikeIcyProtocolError(
java.io.IOException("Connection reset by peer")
)
)
}
}