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 package xyz.cottongin.radio247.audio
import android.util.Log
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Protocol import okhttp3.Protocol
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import java.io.BufferedInputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.net.Socket
import java.net.URL
import java.time.Duration import java.time.Duration
class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause) class ConnectionFailed(message: String, cause: Throwable? = null) : Exception(message, cause)
@@ -31,31 +35,81 @@ class StreamConnection(private val url: String) {
var streamInfo: StreamInfo? = null var streamInfo: StreamInfo? = null
private set private set
private var response: Response? = null private var response: Response? = null
private var rawSocket: Socket? = null
fun open() { 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() val request = Request.Builder()
.url(url) .url(url)
.header("Icy-MetaData", "1") .header("Icy-MetaData", "1")
.header("User-Agent", "Radio247/1.0") .header("User-Agent", "Radio247/1.0")
.build() .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 { try {
val resp = client.newCall(request).execute() val parsed = URL(url)
if (!resp.isSuccessful) { val host = parsed.host ?: throw ConnectionFailed("No host in URL")
resp.close() val port = if (parsed.port != -1) parsed.port else parsed.defaultPort
throw ConnectionFailed("HTTP ${resp.code}") val path = if (parsed.path.isNullOrEmpty()) "/" else parsed.path
}
response = resp val socket = Socket(host, port)
metaint = resp.header("icy-metaint")?.toIntOrNull() 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( streamInfo = StreamInfo(
bitrate = resp.header("icy-br")?.toIntOrNull(), bitrate = headers["icy-br"]?.toIntOrNull(),
ssl = url.startsWith("https", ignoreCase = true), ssl = url.startsWith("https", ignoreCase = true),
contentType = resp.header("Content-Type") contentType = headers["content-type"]
) )
inputStream = resp.body?.byteStream() inputStream = buffered
?: throw ConnectionFailed("Empty response body") } catch (e: ConnectionFailed) {
closeRawSocket()
throw e
} catch (e: IOException) { } 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 { try {
response?.close() response?.close()
} catch (_: IOException) {} } catch (_: IOException) {}
closeRawSocket()
response = null response = null
inputStream = null inputStream = null
metaint = null metaint = null
streamInfo = 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.assertTrue
import org.junit.Assert.fail import org.junit.Assert.fail
import org.junit.Test import org.junit.Test
import java.io.ByteArrayInputStream
import java.net.ProtocolException
class StreamConnectionTest { class StreamConnectionTest {
@@ -120,4 +122,105 @@ class StreamConnectionTest {
conn.close() 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")
)
)
}
} }