From 45a946f82980a0db5159d923e20f4c306c32f673 Mon Sep 17 00:00:00 2001 From: cottongin Date: Tue, 10 Mar 2026 01:18:51 -0400 Subject: [PATCH] feat: add ICY metadata parser with artist/title extraction Made-with: Cursor --- .../cottongin/radio247/audio/IcyMetadata.kt | 7 + .../xyz/cottongin/radio247/audio/IcyParser.kt | 91 ++++++ .../cottongin/radio247/audio/IcyParserTest.kt | 270 ++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 app/src/main/java/xyz/cottongin/radio247/audio/IcyMetadata.kt create mode 100644 app/src/main/java/xyz/cottongin/radio247/audio/IcyParser.kt create mode 100644 app/src/test/java/xyz/cottongin/radio247/audio/IcyParserTest.kt diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/IcyMetadata.kt b/app/src/main/java/xyz/cottongin/radio247/audio/IcyMetadata.kt new file mode 100644 index 0000000..8fe324a --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/audio/IcyMetadata.kt @@ -0,0 +1,7 @@ +package xyz.cottongin.radio247.audio + +data class IcyMetadata( + val raw: String, + val title: String?, + val artist: String? +) diff --git a/app/src/main/java/xyz/cottongin/radio247/audio/IcyParser.kt b/app/src/main/java/xyz/cottongin/radio247/audio/IcyParser.kt new file mode 100644 index 0000000..573af05 --- /dev/null +++ b/app/src/main/java/xyz/cottongin/radio247/audio/IcyParser.kt @@ -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) + } +} diff --git a/app/src/test/java/xyz/cottongin/radio247/audio/IcyParserTest.kt b/app/src/test/java/xyz/cottongin/radio247/audio/IcyParserTest.kt new file mode 100644 index 0000000..a4fb7cb --- /dev/null +++ b/app/src/test/java/xyz/cottongin/radio247/audio/IcyParserTest.kt @@ -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() + 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() + val metadataEvents = mutableListOf() + + 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() + 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() + 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() + val metadataEvents = mutableListOf() + + 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() + val metadataEvents = mutableListOf() + + 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() + 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() + 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() + 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() + val metadataEvents = mutableListOf() + + 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) + } +}