feat: add ICY metadata parser with artist/title extraction
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
package xyz.cottongin.radio247.audio
|
||||||
|
|
||||||
|
data class IcyMetadata(
|
||||||
|
val raw: String,
|
||||||
|
val title: String?,
|
||||||
|
val artist: String?
|
||||||
|
)
|
||||||
91
app/src/main/java/xyz/cottongin/radio247/audio/IcyParser.kt
Normal file
91
app/src/main/java/xyz/cottongin/radio247/audio/IcyParser.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
270
app/src/test/java/xyz/cottongin/radio247/audio/IcyParserTest.kt
Normal file
270
app/src/test/java/xyz/cottongin/radio247/audio/IcyParserTest.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user