feat: add ICY metadata parser with artist/title extraction

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 01:18:51 -04:00
parent bb14a6af53
commit 45a946f829
3 changed files with 368 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
package xyz.cottongin.radio247.audio
data class IcyMetadata(
val raw: String,
val title: String?,
val artist: String?
)

View File

@@ -0,0 +1,91 @@
package xyz.cottongin.radio247.audio
import java.io.InputStream
class IcyParser(
private val input: InputStream,
private val metaint: Int?,
private val onAudioData: (ByteArray, Int, Int) -> Unit,
private val onMetadata: (IcyMetadata) -> Unit
) {
private val buffer = ByteArray(8192)
fun readAll() {
if (metaint == null) {
passthrough()
} else {
parseWithMetadata(metaint)
}
}
private fun passthrough() {
while (true) {
val n = input.read(buffer)
if (n <= 0) break
onAudioData(buffer, 0, n)
}
}
private fun parseWithMetadata(metaint: Int) {
val audioBuf = ByteArray(metaint)
while (true) {
val audioRead = readFully(audioBuf, 0, metaint)
if (audioRead <= 0) break
if (audioRead < metaint) {
onAudioData(audioBuf, 0, audioRead)
break
}
onAudioData(audioBuf, 0, metaint)
val lengthByte = input.read()
if (lengthByte < 0) break
val metadataSize = (lengthByte and 0xFF) * 16
if (metadataSize == 0) continue
val metaBytes = ByteArray(metadataSize)
val metaRead = readFully(metaBytes, 0, metadataSize)
if (metaRead < metadataSize) break
val raw = String(metaBytes, Charsets.UTF_8).trimEnd { it == '\u0000' }
val parsed = parseMetaString(raw)
onMetadata(parsed)
}
}
private fun readFully(buf: ByteArray, off: Int, len: Int): Int {
var total = 0
while (total < len) {
val n = input.read(buf, off + total, len - total)
if (n <= 0) return total
total += n
}
return total
}
private fun parseMetaString(raw: String): IcyMetadata {
val streamTitle = extractStreamTitle(raw)
if (streamTitle != null) {
val separatorIndex = streamTitle.indexOf(" - ")
if (separatorIndex >= 0) {
return IcyMetadata(
raw = raw,
artist = streamTitle.substring(0, separatorIndex).trim(),
title = streamTitle.substring(separatorIndex + 3).trim()
)
}
return IcyMetadata(raw = raw, title = streamTitle.trim(), artist = null)
}
return IcyMetadata(raw = raw, title = null, artist = null)
}
private fun extractStreamTitle(meta: String): String? {
val key = "StreamTitle='"
val start = meta.indexOf(key)
if (start < 0) return null
val valueStart = start + key.length
val end = meta.indexOf("';", valueStart)
if (end < 0) return null
return meta.substring(valueStart, end)
}
}

View File

@@ -0,0 +1,270 @@
package xyz.cottongin.radio247.audio
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.ByteArrayInputStream
class IcyParserTest {
private data class IcyBlock(
val audioData: ByteArray,
val metadata: String? = null // null means length byte = 0
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as IcyBlock
if (!audioData.contentEquals(other.audioData)) return false
if (metadata != other.metadata) return false
return true
}
override fun hashCode(): Int {
var result = audioData.contentHashCode()
result = 31 * result + (metadata?.hashCode() ?: 0)
return result
}
}
private fun padMetadata(meta: String): ByteArray {
val bytes = meta.toByteArray(Charsets.UTF_8)
val paddedSize = ((bytes.size + 15) / 16) * 16
return bytes.copyOf(paddedSize)
}
private fun buildIcyStream(metaint: Int, vararg blocks: IcyBlock): ByteArray {
val output = mutableListOf<Byte>()
for (block in blocks) {
require(block.audioData.size == metaint) {
"Each block must have exactly metaint=$metaint audio bytes, got ${block.audioData.size}"
}
output.addAll(block.audioData.toList())
if (block.metadata == null) {
output.add(0) // length byte = 0
} else {
val padded = padMetadata(block.metadata)
output.add((padded.size / 16).toByte()) // length byte
output.addAll(padded.toList())
}
}
return output.toByteArray()
}
@Test
fun separatesAudioAndMetadataCorrectly() {
val metaint = 8
val audioChunk = ByteArray(8) { 0x42 }
val stream = buildIcyStream(
metaint,
IcyBlock(audioChunk, "StreamTitle='Test Title';"),
IcyBlock(audioChunk, null)
)
val audioCollected = mutableListOf<Byte>()
val metadataEvents = mutableListOf<IcyMetadata>()
IcyParser(
input = ByteArrayInputStream(stream),
metaint = metaint,
onAudioData = { buf, off, len -> (0 until len).forEach { audioCollected.add(buf[off + it]) } },
onMetadata = { metadataEvents.add(it) }
).readAll()
assertEquals(16, audioCollected.size)
assertTrue(audioCollected.all { it == 0x42.toByte() })
assertEquals(1, metadataEvents.size)
assertEquals("Test Title", metadataEvents[0].title)
assertEquals(null, metadataEvents[0].artist)
}
@Test
fun parsesArtistAndTitleFromStreamTitle() {
val metaint = 8
val audioChunk = ByteArray(8) { 0x00 }
val stream = buildIcyStream(
metaint,
IcyBlock(audioChunk, "StreamTitle='Daft Punk - Around The World';")
)
val metadataEvents = mutableListOf<IcyMetadata>()
IcyParser(
input = ByteArrayInputStream(stream),
metaint = metaint,
onAudioData = { _, _, _ -> },
onMetadata = { metadataEvents.add(it) }
).readAll()
assertEquals(1, metadataEvents.size)
assertEquals("Daft Punk", metadataEvents[0].artist)
assertEquals("Around The World", metadataEvents[0].title)
}
@Test
fun handlesTitleWithoutArtistSeparator() {
val metaint = 8
val audioChunk = ByteArray(8) { 0x00 }
val stream = buildIcyStream(
metaint,
IcyBlock(audioChunk, "StreamTitle='The Morning Show';")
)
val metadataEvents = mutableListOf<IcyMetadata>()
IcyParser(
input = ByteArrayInputStream(stream),
metaint = metaint,
onAudioData = { _, _, _ -> },
onMetadata = { metadataEvents.add(it) }
).readAll()
assertEquals(1, metadataEvents.size)
assertEquals(null, metadataEvents[0].artist)
assertEquals("The Morning Show", metadataEvents[0].title)
}
@Test
fun handlesEmptyMetadataBlocks() {
val metaint = 8
val audioChunk = ByteArray(8) { 0xAB.toByte() }
val stream = buildIcyStream(
metaint,
IcyBlock(audioChunk, null),
IcyBlock(audioChunk, null),
IcyBlock(audioChunk, null)
)
val audioCollected = mutableListOf<Byte>()
val metadataEvents = mutableListOf<IcyMetadata>()
IcyParser(
input = ByteArrayInputStream(stream),
metaint = metaint,
onAudioData = { buf, off, len -> (0 until len).forEach { audioCollected.add(buf[off + it]) } },
onMetadata = { metadataEvents.add(it) }
).readAll()
assertEquals(24, audioCollected.size)
assertTrue(audioCollected.all { it == 0xAB.toByte() })
assertEquals(0, metadataEvents.size)
}
@Test
fun passthroughModeWhenMetaintIsNull() {
val data = ByteArray(1000) { it.toByte() }
val audioCollected = mutableListOf<Byte>()
val metadataEvents = mutableListOf<IcyMetadata>()
IcyParser(
input = ByteArrayInputStream(data),
metaint = null,
onAudioData = { buf, off, len -> (0 until len).forEach { audioCollected.add(buf[off + it]) } },
onMetadata = { metadataEvents.add(it) }
).readAll()
assertEquals(1000, audioCollected.size)
assertTrue(audioCollected.withIndex().all { (i, b) -> b == data[i] })
assertEquals(0, metadataEvents.size)
}
@Test
fun handlesMaximumSizeMetadata() {
val metaint = 8
val audioChunk = ByteArray(8) { 0x00 }
// 255 * 16 = 4080 bytes. Build metadata that pads to exactly that size.
val metaContent = "StreamTitle='Big';" + "x".repeat(4060)
val padded = padMetadata(metaContent)
assertEquals(4080, padded.size)
assertEquals(255, padded.size / 16)
val stream = buildIcyStream(metaint, IcyBlock(audioChunk, metaContent))
val metadataEvents = mutableListOf<IcyMetadata>()
IcyParser(
input = ByteArrayInputStream(stream),
metaint = metaint,
onAudioData = { _, _, _ -> },
onMetadata = { metadataEvents.add(it) }
).readAll()
assertEquals(1, metadataEvents.size)
assertEquals("Big", metadataEvents[0].title)
}
@Test
fun handlesMultipleConsecutiveMetadataChanges() {
val metaint = 8
val audioChunk = ByteArray(8) { 0x00 }
val stream = buildIcyStream(
metaint,
IcyBlock(audioChunk, "StreamTitle='First Song';"),
IcyBlock(audioChunk, "StreamTitle='Second Song';"),
IcyBlock(audioChunk, "StreamTitle='Third Song';")
)
val metadataEvents = mutableListOf<IcyMetadata>()
IcyParser(
input = ByteArrayInputStream(stream),
metaint = metaint,
onAudioData = { _, _, _ -> },
onMetadata = { metadataEvents.add(it) }
).readAll()
assertEquals(3, metadataEvents.size)
assertEquals("First Song", metadataEvents[0].title)
assertEquals("Second Song", metadataEvents[1].title)
assertEquals("Third Song", metadataEvents[2].title)
}
@Test
fun handlesMetadataWithStreamUrlField() {
val metaint = 8
val audioChunk = ByteArray(8) { 0x00 }
val stream = buildIcyStream(
metaint,
IcyBlock(audioChunk, "StreamTitle='Song';StreamUrl='http://art.jpg';")
)
val metadataEvents = mutableListOf<IcyMetadata>()
IcyParser(
input = ByteArrayInputStream(stream),
metaint = metaint,
onAudioData = { _, _, _ -> },
onMetadata = { metadataEvents.add(it) }
).readAll()
assertEquals(1, metadataEvents.size)
assertEquals("Song", metadataEvents[0].title)
assertTrue(
metadataEvents[0].raw.contains("StreamTitle") &&
metadataEvents[0].raw.contains("StreamUrl")
)
}
@Test
fun handlesTruncatedStreamGracefully() {
val metaint = 8
val audioChunk = ByteArray(8) { 0x42 }
val fullStream = buildIcyStream(
metaint,
IcyBlock(audioChunk, null),
IcyBlock(audioChunk, null)
)
// Truncate mid-second-audio-chunk (after 8 audio + 1 length byte = 9 bytes, plus 4 more audio = 13 total)
val truncated = fullStream.copyOf(13)
val audioCollected = mutableListOf<Byte>()
val metadataEvents = mutableListOf<IcyMetadata>()
IcyParser(
input = ByteArrayInputStream(truncated),
metaint = metaint,
onAudioData = { buf, off, len -> (0 until len).forEach { audioCollected.add(buf[off + it]) } },
onMetadata = { metadataEvents.add(it) }
).readAll()
// First 8 bytes audio, then 4 bytes of second chunk before EOF
assertEquals(12, audioCollected.size)
assertTrue(audioCollected.all { it == 0x42.toByte() })
assertEquals(0, metadataEvents.size)
}
}