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:
@@ -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,15 +35,28 @@ 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()
|
||||
|
||||
try {
|
||||
val resp = client.newCall(request).execute()
|
||||
if (!resp.isSuccessful) {
|
||||
resp.close()
|
||||
@@ -54,8 +71,45 @@ class StreamConnection(private val url: String) {
|
||||
)
|
||||
inputStream = resp.body?.byteStream()
|
||||
?: throw ConnectionFailed("Empty response body")
|
||||
}
|
||||
|
||||
private fun openWithRawSocket() {
|
||||
try {
|
||||
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 = headers["icy-br"]?.toIntOrNull(),
|
||||
ssl = url.startsWith("https", ignoreCase = true),
|
||||
contentType = headers["content-type"]
|
||||
)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user