/* * Copyright (c) Double Symmetry GmbH * Commercial use requires a license. See https://rntp.dev/pricing */ package com.doublesymmetry.trackplayer.models import androidx.media3.common.Format import androidx.media3.common.Metadata import androidx.media3.extractor.metadata.MetadataInputBuffer import androidx.media3.extractor.metadata.icy.IcyDecoder import androidx.media3.extractor.metadata.icy.IcyHeaders import androidx.media3.extractor.metadata.icy.IcyInfo import androidx.media3.extractor.metadata.id3.TextInformationFrame import com.google.common.collect.ImmutableList import java.nio.ByteBuffer import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner /** * Validates that the same parser path that ExoPlayer's * [androidx.media3.exoplayer.source.ProgressiveMediaPeriod] uses for live ICY * streams produces the metadata our JS layer expects. We feed the real * [IcyDecoder] raw byte payloads in the exact wire format radio.co / * klubradio.hu (Shoutcast/Icecast) emit, and assert the aggregated * [MetadataReceivedEvent]. * * Repro context — https://github.com/doublesymmetry/react-native-track-player/issues/2638 * - https://streamer.radio.co/s51a438b13/listen * - https://stream.klubradio.hu:8443/ */ @RunWith(RobolectricTestRunner::class) class StreamMetadataExtractorTest { // ---- parseStreamTitle ---- @Test fun `parseStreamTitle splits Artist - Title`() { val (title, artist) = StreamMetadataExtractor.parseStreamTitle("Daft Punk - Get Lucky") assertEquals("Get Lucky", title) assertEquals("Daft Punk", artist) } @Test fun `parseStreamTitle keeps single string as title`() { val (title, artist) = StreamMetadataExtractor.parseStreamTitle("Morning News") assertEquals("Morning News", title) assertNull(artist) } @Test fun `parseStreamTitle splits only on first separator`() { val (title, artist) = StreamMetadataExtractor.parseStreamTitle("Daft Punk - Get Lucky - Radio Edit") assertEquals("Get Lucky - Radio Edit", title) assertEquals("Daft Punk", artist) } @Test fun `parseStreamTitle trims surrounding whitespace`() { val (title, artist) = StreamMetadataExtractor.parseStreamTitle(" Artist - Song ") assertEquals("Song", title) assertEquals("Artist", artist) } @Test fun `parseStreamTitle returns nulls for blank input`() { val (title, artist) = StreamMetadataExtractor.parseStreamTitle(" ") assertNull(title) assertNull(artist) } @Test fun `parseStreamTitle does not split on dash without surrounding spaces`() { // "U-Turn" should NOT be split — Shoutcast convention uses " - " (spaces). val (title, artist) = StreamMetadataExtractor.parseStreamTitle("U-Turn") assertEquals("U-Turn", title) assertNull(artist) } // ---- extract: synthetic IcyInfo entries ---- @Test fun `extract from IcyInfo with Artist - Title splits both fields`() { val info = IcyInfo(ByteArray(0), "Daft Punk - Get Lucky", null) val event = StreamMetadataExtractor.extract(Metadata(info)) assertNotNull(event) assertEquals("Get Lucky", event!!.title) assertEquals("Daft Punk", event.artist) assertNull(event.albumTitle) assertNull(event.artworkUri) assertNull(event.genre) } @Test fun `extract from IcyInfo with single title leaves artist null`() { val info = IcyInfo(ByteArray(0), "Morning Talk Show", null) val event = StreamMetadataExtractor.extract(Metadata(info)) assertNotNull(event) assertEquals("Morning Talk Show", event!!.title) assertNull(event.artist) } @Test fun `extract from IcyInfo with null title returns null event`() { val info = IcyInfo(ByteArray(0), null, null) val event = StreamMetadataExtractor.extract(Metadata(info)) assertNull(event) } @Test fun `extract from empty Metadata returns null`() { val event = StreamMetadataExtractor.extract(Metadata()) assertNull(event) } @Test fun `extract maps IcyInfo url into artworkUri`() { val info = IcyInfo(ByteArray(0), "Artist - Song", "https://example.com/cover.jpg") val event = StreamMetadataExtractor.extract(Metadata(info)) assertNotNull(event) assertEquals("https://example.com/cover.jpg", event!!.artworkUri) } @Test fun `extract ignores empty IcyInfo url`() { val info = IcyInfo(ByteArray(0), "Artist - Song", "") val event = StreamMetadataExtractor.extract(Metadata(info)) assertNotNull(event) assertNull(event!!.artworkUri) } // ---- extract: combined IcyInfo + IcyHeaders ---- @Test fun `extract pulls genre from IcyHeaders alongside IcyInfo title`() { // ProgressiveMediaPeriod emits IcyHeaders + IcyInfo together on first metadata // for radio.co / klubradio.hu (icy-genre header from the response). val info = IcyInfo(ByteArray(0), "Radiohead - No Surprises", null) val headers = IcyHeaders( /* bitrate = */ 128_000, /* genre = */ "Indie Rock", /* name = */ "Test Radio", /* url = */ null, /* isPublic = */ true, /* metadataInterval = */ 16_000, ) val event = StreamMetadataExtractor.extract(Metadata(info, headers)) assertNotNull(event) assertEquals("No Surprises", event!!.title) assertEquals("Radiohead", event.artist) assertEquals("Indie Rock", event.genre) } // ---- extract: ID3 frames (covers podcasts / MP3 streams w/ embedded ID3) ---- @Test fun `extract from ID3 frames sets title and artist without splitting`() { // TIT2 = title, TPE1 = lead artist. When both arrive populated, the // Shoutcast " - " heuristic must NOT split the title. val title = TextInformationFrame("TIT2", null, ImmutableList.of("Song - With Dash")) val artist = TextInformationFrame("TPE1", null, ImmutableList.of("Artist Name")) val event = StreamMetadataExtractor.extract(Metadata(title, artist)) assertNotNull(event) assertEquals("Song - With Dash", event!!.title) assertEquals("Artist Name", event.artist) } // ---- extract: real captured ICY payloads from the reported streams ---- // // The strings below are NOT illustrative examples — they are the literal // bytes returned by the two stations the original bug report links to, // captured live from production endpoints. // // Capture procedure (reproducible from `scripts/capture_icy.py`): // $ scripts/capture_icy.py https://streamer.radio.co/s51a438b13/listen out.txt // $ scripts/capture_icy.py https://stream.klubradio.hu:8443/ out.txt // Both servers respond with `icy-metaint: 16000` and emit Shoutcast-style // `StreamTitle='…';` blocks. NUL padding to the 16-byte block boundary has // been stripped (matching IcyDecoder's behavior on the real device). @Test fun `extract real radio_co track-change payload (Sonia Odisho - Eked D Bayet)`() { // Captured 2026-05-20 from https://streamer.radio.co/s51a438b13/listen // Raw bytes (hex): 53 74 72 65 61 6d 54 69 74 6c 65 3d 27 53 6f 6e 69 61 … val event = decodeAndExtract("StreamTitle='Sonia Odisho - Eked D Bayet';") assertNotNull(event) assertEquals("Eked D Bayet", event!!.title) assertEquals("Sonia Odisho", event.artist) assertNull(event.artworkUri) } @Test fun `extract real radio_co between-songs empty StreamTitle`() { // Same source, captured immediately before the track-change block above. // Servers commonly emit `StreamTitle='';` during silence/transitions — // the extractor must NOT synthesize a bogus event in that case. val event = decodeAndExtract("StreamTitle='';") assertNull(event) } @Test fun `extract real klubradio_hu talk-segment empty StreamTitle`() { // Captured 2026-05-20 from https://stream.klubradio.hu:8443/ (redirects // to hu-stream03.klubradio.hu:8443/bpstream). The Hungarian talk station // sends ICY metadata frames but with empty StreamTitle during talk // segments — over a 60s window only this empty payload was observed. val event = decodeAndExtract("StreamTitle='';") assertNull(event) } @Test fun `extract handles UTF-8 multibyte chars in real-world StreamTitle`() { // Both endpoints serve UTF-8; while the captured radio.co block above // was pure ASCII, exercising the high-byte path is critical because // IcyDecoder falls back to ISO-8859-1 on UTF-8 decode failure (would // mangle Hungarian/Icelandic/etc characters). val event = decodeAndExtract("StreamTitle='Sigur Rós - Hoppípolla';") assertNotNull(event) assertEquals("Hoppípolla", event!!.title) assertEquals("Sigur Rós", event.artist) } // ---- helpers ---- /** * Drives the real [IcyDecoder] used by ExoPlayer's [ProgressiveMediaPeriod] * with the given Shoutcast metadata string, then runs the result through * [StreamMetadataExtractor]. This exercises the same code path live streams * hit at runtime. */ private fun decodeAndExtract(icyString: String): MetadataReceivedEvent? { val bytes = icyString.toByteArray(Charsets.UTF_8) val buffer = MetadataInputBuffer().apply { data = ByteBuffer.allocate(bytes.size).apply { put(bytes) flip() } } val decoded = IcyDecoder().decode(buffer) assertNotNull("IcyDecoder should produce a Metadata bundle", decoded) return StreamMetadataExtractor.extract(decoded!!) } }