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
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user