@rntp/player 5.0.0-beta.6 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/android/src/main/java/com/doublesymmetry/trackplayer/HeaderInjectingDataSourceFactory.kt +5 -1
  2. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +43 -3
  3. package/android/src/main/java/com/doublesymmetry/trackplayer/models/EmitEventType.kt +30 -0
  4. package/android/src/main/java/com/doublesymmetry/trackplayer/models/MetadataApplier.kt +59 -0
  5. package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +10 -0
  6. package/android/src/main/java/com/doublesymmetry/trackplayer/models/StreamMetadataExtractor.kt +98 -0
  7. package/android/src/main/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItem.kt +20 -8
  8. package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +33 -0
  9. package/android/src/test/java/com/doublesymmetry/trackplayer/models/MetadataApplierTest.kt +243 -0
  10. package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +33 -0
  11. package/android/src/test/java/com/doublesymmetry/trackplayer/models/StreamMetadataExtractorTest.kt +254 -0
  12. package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +202 -0
  13. package/ios/TrackPlayer.swift +49 -2
  14. package/ios/models/EmitEvent.swift +28 -0
  15. package/ios/models/MediaItem.swift +8 -3
  16. package/ios/tests/AVPlayerEngineIntegrationTests.swift +13 -4
  17. package/lib/commonjs/audio.js +1 -0
  18. package/lib/commonjs/audio.js.map +1 -1
  19. package/lib/commonjs/events/MediaMetadataChanged.js +2 -0
  20. package/lib/commonjs/events/MediaMetadataChanged.js.map +1 -0
  21. package/lib/commonjs/events/index.js +13 -0
  22. package/lib/commonjs/events/index.js.map +1 -1
  23. package/lib/commonjs/hooks/useActiveMediaItem.js +9 -3
  24. package/lib/commonjs/hooks/useActiveMediaItem.js.map +1 -1
  25. package/lib/commonjs/hooks/useProgress.js.map +1 -1
  26. package/lib/commonjs/interfaces/PlayerConfig.js +1 -0
  27. package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
  28. package/lib/module/audio.js +1 -0
  29. package/lib/module/audio.js.map +1 -1
  30. package/lib/module/events/MediaMetadataChanged.js +2 -0
  31. package/lib/module/events/MediaMetadataChanged.js.map +1 -0
  32. package/lib/module/events/index.js +2 -0
  33. package/lib/module/events/index.js.map +1 -1
  34. package/lib/module/hooks/useActiveMediaItem.js +9 -3
  35. package/lib/module/hooks/useActiveMediaItem.js.map +1 -1
  36. package/lib/module/hooks/useProgress.js.map +1 -1
  37. package/lib/module/interfaces/PlayerConfig.js +1 -0
  38. package/lib/module/interfaces/PlayerConfig.js.map +1 -1
  39. package/lib/typescript/src/audio.d.ts.map +1 -1
  40. package/lib/typescript/src/events/MediaMetadataChanged.d.ts +31 -0
  41. package/lib/typescript/src/events/MediaMetadataChanged.d.ts.map +1 -0
  42. package/lib/typescript/src/events/MetadataReceived.d.ts +15 -0
  43. package/lib/typescript/src/events/MetadataReceived.d.ts.map +1 -1
  44. package/lib/typescript/src/events/index.d.ts +4 -0
  45. package/lib/typescript/src/events/index.d.ts.map +1 -1
  46. package/lib/typescript/src/hooks/useActiveMediaItem.d.ts +8 -2
  47. package/lib/typescript/src/hooks/useActiveMediaItem.d.ts.map +1 -1
  48. package/lib/typescript/src/hooks/useProgress.d.ts.map +1 -1
  49. package/lib/typescript/src/interfaces/MediaItem.d.ts +15 -0
  50. package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
  51. package/lib/typescript/src/interfaces/PlayerConfig.d.ts +15 -0
  52. package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
  53. package/package.json +1 -1
  54. package/src/audio.ts +3 -0
  55. package/src/events/MediaMetadataChanged.ts +31 -0
  56. package/src/events/MetadataReceived.ts +15 -0
  57. package/src/events/index.ts +4 -0
  58. package/src/hooks/useActiveMediaItem.ts +9 -3
  59. package/src/hooks/useProgress.ts +1 -1
  60. package/src/interfaces/MediaItem.ts +15 -0
  61. package/src/interfaces/PlayerConfig.ts +16 -0
@@ -0,0 +1,243 @@
1
+ /*
2
+ * Copyright (c) Double Symmetry GmbH
3
+ * Commercial use requires a license. See https://rntp.dev/pricing
4
+ */
5
+
6
+ package com.doublesymmetry.trackplayer.models
7
+
8
+ import androidx.media3.common.MediaItem
9
+ import androidx.media3.common.MediaMetadata
10
+ import androidx.media3.exoplayer.ExoPlayer
11
+ import org.junit.After
12
+ import org.junit.Assert.assertEquals
13
+ import org.junit.Assert.assertFalse
14
+ import org.junit.Assert.assertNull
15
+ import org.junit.Assert.assertTrue
16
+ import org.junit.Before
17
+ import org.junit.Test
18
+ import org.junit.runner.RunWith
19
+ import org.robolectric.RobolectricTestRunner
20
+ import org.robolectric.RuntimeEnvironment
21
+ import org.robolectric.annotation.Config
22
+
23
+ /**
24
+ * Exercises [MetadataApplier.applyToCurrentMediaItem] against a real
25
+ * [ExoPlayer] under Robolectric. We use [ExoPlayer] directly (rather than the
26
+ * [androidx.media3.session.MediaController] used at runtime) because both
27
+ * implement the [androidx.media3.common.Player] interface that the applier
28
+ * targets — and ExoPlayer is the only one we can stand up without a service.
29
+ */
30
+ @RunWith(RobolectricTestRunner::class)
31
+ @Config(sdk = [33])
32
+ class MetadataApplierTest {
33
+
34
+ private lateinit var player: ExoPlayer
35
+
36
+ @Before
37
+ fun setUp() {
38
+ val context = RuntimeEnvironment.getApplication()
39
+ player = ExoPlayer.Builder(context).build()
40
+ }
41
+
42
+ @After
43
+ fun tearDown() {
44
+ player.release()
45
+ }
46
+
47
+ private fun buildItem(
48
+ id: String,
49
+ title: String? = null,
50
+ artist: String? = null,
51
+ albumTitle: String? = null,
52
+ genre: String? = null,
53
+ ): MediaItem = MediaItem.Builder()
54
+ .setMediaId(id)
55
+ .setUri("https://example.com/$id.mp3")
56
+ .setMediaMetadata(
57
+ MediaMetadata.Builder().apply {
58
+ title?.let { setTitle(it) }
59
+ artist?.let { setArtist(it) }
60
+ albumTitle?.let { setAlbumTitle(it) }
61
+ genre?.let { setGenre(it) }
62
+ }.build()
63
+ )
64
+ .build()
65
+
66
+ // ---- Happy path: live ICY-style updates rewrite the current item ----
67
+
68
+ @Test
69
+ fun `applies title and artist to current item`() {
70
+ player.setMediaItems(listOf(buildItem("radio")))
71
+ val event = MetadataReceivedEvent(title = "Hoppípolla", artist = "Sigur Rós")
72
+
73
+ val applied = MetadataApplier.applyToCurrentMediaItem(player, event)
74
+
75
+ assertTrue(applied)
76
+ val updated = player.getMediaItemAt(0).mediaMetadata
77
+ assertEquals("Hoppípolla", updated.title?.toString())
78
+ assertEquals("Sigur Rós", updated.artist?.toString())
79
+ }
80
+
81
+ @Test
82
+ fun `preserves existing user-supplied fields the event does not touch`() {
83
+ // Mirrors the real-world case: an app queues a radio station with a fixed
84
+ // `genre` / `albumTitle` and the stream subsequently sends new ICY titles
85
+ // without artwork/genre. Those originals must survive the rewrite.
86
+ player.setMediaItems(
87
+ listOf(
88
+ buildItem(
89
+ id = "radio",
90
+ title = "Static Station Title",
91
+ artist = null,
92
+ albumTitle = "My Radios",
93
+ genre = "Rock",
94
+ )
95
+ )
96
+ )
97
+ val event = MetadataReceivedEvent(title = "New Song", artist = "New Artist")
98
+
99
+ MetadataApplier.applyToCurrentMediaItem(player, event)
100
+
101
+ val updated = player.getMediaItemAt(0).mediaMetadata
102
+ assertEquals("New Song", updated.title?.toString())
103
+ assertEquals("New Artist", updated.artist?.toString())
104
+ assertEquals("My Radios", updated.albumTitle?.toString())
105
+ assertEquals("Rock", updated.genre?.toString())
106
+ }
107
+
108
+ @Test
109
+ fun `overwrites previous stream-supplied title on next update`() {
110
+ // Two consecutive ICY updates: second one fully replaces the first's
111
+ // title + artist (same currentIndex, so the rewrite is in place).
112
+ player.setMediaItems(listOf(buildItem("radio")))
113
+
114
+ MetadataApplier.applyToCurrentMediaItem(
115
+ player,
116
+ MetadataReceivedEvent(title = "Song A", artist = "Artist A"),
117
+ )
118
+ MetadataApplier.applyToCurrentMediaItem(
119
+ player,
120
+ MetadataReceivedEvent(title = "Song B", artist = "Artist B"),
121
+ )
122
+
123
+ val updated = player.getMediaItemAt(0).mediaMetadata
124
+ assertEquals("Song B", updated.title?.toString())
125
+ assertEquals("Artist B", updated.artist?.toString())
126
+ }
127
+
128
+ @Test
129
+ fun `null fields in event do not clobber existing values (sticky semantics)`() {
130
+ // Match iOS withMetadata behavior: nil from stream means "leave alone",
131
+ // not "clear". Useful when a station sends artist on one update and only
132
+ // title on the next.
133
+ player.setMediaItems(listOf(buildItem(id = "radio", artist = "Existing Artist")))
134
+ val event = MetadataReceivedEvent(title = "Just a Title", artist = null)
135
+
136
+ MetadataApplier.applyToCurrentMediaItem(player, event)
137
+
138
+ val updated = player.getMediaItemAt(0).mediaMetadata
139
+ assertEquals("Just a Title", updated.title?.toString())
140
+ assertEquals("Existing Artist", updated.artist?.toString())
141
+ }
142
+
143
+ // ---- URI preserved → playback uninterrupted ----
144
+
145
+ @Test
146
+ fun `preserves URI on rewrite`() {
147
+ player.setMediaItems(listOf(buildItem("radio")))
148
+ val originalUri = player.getMediaItemAt(0).localConfiguration?.uri.toString()
149
+
150
+ MetadataApplier.applyToCurrentMediaItem(
151
+ player,
152
+ MetadataReceivedEvent(title = "Song", artist = "Artist"),
153
+ )
154
+
155
+ val newUri = player.getMediaItemAt(0).localConfiguration?.uri.toString()
156
+ assertEquals(originalUri, newUri)
157
+ }
158
+
159
+ @Test
160
+ fun `preserves mediaId on rewrite`() {
161
+ player.setMediaItems(listOf(buildItem("my-radio-id", title = "Original")))
162
+
163
+ MetadataApplier.applyToCurrentMediaItem(
164
+ player,
165
+ MetadataReceivedEvent(title = "Live Title", artist = null),
166
+ )
167
+
168
+ assertEquals("my-radio-id", player.getMediaItemAt(0).mediaId)
169
+ }
170
+
171
+ // ---- artworkUri / albumTitle / genre coverage ----
172
+
173
+ @Test
174
+ fun `applies artworkUri and genre from event`() {
175
+ player.setMediaItems(listOf(buildItem("radio")))
176
+ val event = MetadataReceivedEvent(
177
+ title = "Song",
178
+ artist = "Artist",
179
+ albumTitle = "Album",
180
+ artworkUri = "https://art.example.com/cover.jpg",
181
+ genre = "Indie",
182
+ )
183
+
184
+ MetadataApplier.applyToCurrentMediaItem(player, event)
185
+
186
+ val updated = player.getMediaItemAt(0).mediaMetadata
187
+ assertEquals("Album", updated.albumTitle?.toString())
188
+ assertEquals("https://art.example.com/cover.jpg", updated.artworkUri?.toString())
189
+ assertEquals("Indie", updated.genre?.toString())
190
+ }
191
+
192
+ // ---- No current item → no-op ----
193
+
194
+ @Test
195
+ fun `returns false when queue is empty`() {
196
+ val event = MetadataReceivedEvent(title = "Won't Apply", artist = null)
197
+
198
+ val applied = MetadataApplier.applyToCurrentMediaItem(player, event)
199
+
200
+ assertFalse(applied)
201
+ assertEquals(0, player.mediaItemCount)
202
+ }
203
+
204
+ // ---- Affects only the current index, not siblings ----
205
+
206
+ @Test
207
+ fun `only mutates the current item, not the rest of the queue`() {
208
+ player.setMediaItems(
209
+ listOf(
210
+ buildItem("track-1", title = "Title 1"),
211
+ buildItem("track-2", title = "Title 2"),
212
+ buildItem("track-3", title = "Title 3"),
213
+ )
214
+ )
215
+ // currentIndex defaults to 0 — that's the one we expect to mutate.
216
+ val event = MetadataReceivedEvent(title = "Live Title 1", artist = null)
217
+
218
+ MetadataApplier.applyToCurrentMediaItem(player, event)
219
+
220
+ assertEquals("Live Title 1", player.getMediaItemAt(0).mediaMetadata.title?.toString())
221
+ assertEquals("Title 2", player.getMediaItemAt(1).mediaMetadata.title?.toString())
222
+ assertEquals("Title 3", player.getMediaItemAt(2).mediaMetadata.title?.toString())
223
+ }
224
+
225
+ // ---- Edge case: event with all null fields contributes nothing ----
226
+
227
+ @Test
228
+ fun `event with all null fields leaves item unchanged but still reports applied`() {
229
+ // The applier doesn't gate on empty events — that's the caller's job
230
+ // (StreamMetadataExtractor.extract already returns null for empty
231
+ // metadata blocks, so this code path is mostly defensive).
232
+ player.setMediaItems(listOf(buildItem(id = "radio", title = "Original Title")))
233
+ val event = MetadataReceivedEvent(
234
+ title = null, artist = null, albumTitle = null, artworkUri = null, genre = null,
235
+ )
236
+
237
+ val applied = MetadataApplier.applyToCurrentMediaItem(player, event)
238
+
239
+ assertTrue(applied)
240
+ assertEquals("Original Title", player.getMediaItemAt(0).mediaMetadata.title?.toString())
241
+ assertNull(player.getMediaItemAt(0).mediaMetadata.artist)
242
+ }
243
+ }
@@ -22,6 +22,7 @@ class PlayerConfigTest {
22
22
 
23
23
  assertEquals("music", config.contentType)
24
24
  assertTrue(config.handleAudioBecomingNoisy)
25
+ assertTrue(config.autoUpdateMetadataFromStream)
25
26
  assertEquals(WakeMode.NONE, config.wakeMode)
26
27
  assertFalse(config.skipSilenceEnabled)
27
28
  assertEquals(listOf(PlayerCommand.PLAY_PAUSE), config.availableCommands)
@@ -48,6 +49,7 @@ class PlayerConfigTest {
48
49
  val config = PlayerConfig(
49
50
  contentType = "speech",
50
51
  handleAudioBecomingNoisy = false,
52
+ autoUpdateMetadataFromStream = false,
51
53
  wakeMode = WakeMode.NETWORK,
52
54
  skipSilenceEnabled = true,
53
55
  forwardInterval = 30L,
@@ -66,6 +68,7 @@ class PlayerConfigTest {
66
68
 
67
69
  assertEquals("speech", loaded.contentType)
68
70
  assertFalse(loaded.handleAudioBecomingNoisy)
71
+ assertFalse(loaded.autoUpdateMetadataFromStream)
69
72
  assertEquals(WakeMode.NETWORK, loaded.wakeMode)
70
73
  assertTrue(loaded.skipSilenceEnabled)
71
74
  assertEquals(30L, loaded.forwardInterval)
@@ -382,6 +385,36 @@ class PlayerConfigTest {
382
385
  assertEquals(RemoteControlHandling.JS, config.remoteControlHandling)
383
386
  }
384
387
 
388
+ @Test
389
+ fun `fromReadableMap defaults autoUpdateMetadataFromStream to true`() {
390
+ val map = mockk<ReadableMap>(relaxed = true) {
391
+ every { getString("contentType") } returns null
392
+ every { getMap("android") } returns null
393
+ every { getMap("cache") } returns null
394
+ every { getMap("progressSync") } returns null
395
+ every { hasKey(any()) } returns false
396
+ }
397
+
398
+ val config = PlayerConfig.fromReadableMap(map)
399
+ assertTrue(config.autoUpdateMetadataFromStream)
400
+ }
401
+
402
+ @Test
403
+ fun `fromReadableMap parses autoUpdateMetadataFromStream when false`() {
404
+ val map = mockk<ReadableMap>(relaxed = true) {
405
+ every { getString("contentType") } returns null
406
+ every { getMap("android") } returns null
407
+ every { getMap("cache") } returns null
408
+ every { getMap("progressSync") } returns null
409
+ every { hasKey(any()) } returns false
410
+ every { hasKey("autoUpdateMetadataFromStream") } returns true
411
+ every { getBoolean("autoUpdateMetadataFromStream") } returns false
412
+ }
413
+
414
+ val config = PlayerConfig.fromReadableMap(map)
415
+ assertFalse(config.autoUpdateMetadataFromStream)
416
+ }
417
+
385
418
  @Test
386
419
  fun `fromReadableMap with null android section defaults wakeMode to NONE`() {
387
420
  val map = mockk<ReadableMap>(relaxed = true) {
@@ -0,0 +1,254 @@
1
+ /*
2
+ * Copyright (c) Double Symmetry GmbH
3
+ * Commercial use requires a license. See https://rntp.dev/pricing
4
+ */
5
+
6
+ package com.doublesymmetry.trackplayer.models
7
+
8
+ import androidx.media3.common.Format
9
+ import androidx.media3.common.Metadata
10
+ import androidx.media3.extractor.metadata.MetadataInputBuffer
11
+ import androidx.media3.extractor.metadata.icy.IcyDecoder
12
+ import androidx.media3.extractor.metadata.icy.IcyHeaders
13
+ import androidx.media3.extractor.metadata.icy.IcyInfo
14
+ import androidx.media3.extractor.metadata.id3.TextInformationFrame
15
+ import com.google.common.collect.ImmutableList
16
+ import java.nio.ByteBuffer
17
+ import org.junit.Assert.assertEquals
18
+ import org.junit.Assert.assertNotNull
19
+ import org.junit.Assert.assertNull
20
+ import org.junit.Test
21
+ import org.junit.runner.RunWith
22
+ import org.robolectric.RobolectricTestRunner
23
+
24
+ /**
25
+ * Validates that the same parser path that ExoPlayer's
26
+ * [androidx.media3.exoplayer.source.ProgressiveMediaPeriod] uses for live ICY
27
+ * streams produces the metadata our JS layer expects. We feed the real
28
+ * [IcyDecoder] raw byte payloads in the exact wire format radio.co /
29
+ * klubradio.hu (Shoutcast/Icecast) emit, and assert the aggregated
30
+ * [MetadataReceivedEvent].
31
+ *
32
+ * Repro context — https://github.com/doublesymmetry/react-native-track-player/issues/2638
33
+ * - https://streamer.radio.co/s51a438b13/listen
34
+ * - https://stream.klubradio.hu:8443/
35
+ */
36
+ @RunWith(RobolectricTestRunner::class)
37
+ class StreamMetadataExtractorTest {
38
+
39
+ // ---- parseStreamTitle ----
40
+
41
+ @Test
42
+ fun `parseStreamTitle splits Artist - Title`() {
43
+ val (title, artist) = StreamMetadataExtractor.parseStreamTitle("Daft Punk - Get Lucky")
44
+ assertEquals("Get Lucky", title)
45
+ assertEquals("Daft Punk", artist)
46
+ }
47
+
48
+ @Test
49
+ fun `parseStreamTitle keeps single string as title`() {
50
+ val (title, artist) = StreamMetadataExtractor.parseStreamTitle("Morning News")
51
+ assertEquals("Morning News", title)
52
+ assertNull(artist)
53
+ }
54
+
55
+ @Test
56
+ fun `parseStreamTitle splits only on first separator`() {
57
+ val (title, artist) =
58
+ StreamMetadataExtractor.parseStreamTitle("Daft Punk - Get Lucky - Radio Edit")
59
+ assertEquals("Get Lucky - Radio Edit", title)
60
+ assertEquals("Daft Punk", artist)
61
+ }
62
+
63
+ @Test
64
+ fun `parseStreamTitle trims surrounding whitespace`() {
65
+ val (title, artist) = StreamMetadataExtractor.parseStreamTitle(" Artist - Song ")
66
+ assertEquals("Song", title)
67
+ assertEquals("Artist", artist)
68
+ }
69
+
70
+ @Test
71
+ fun `parseStreamTitle returns nulls for blank input`() {
72
+ val (title, artist) = StreamMetadataExtractor.parseStreamTitle(" ")
73
+ assertNull(title)
74
+ assertNull(artist)
75
+ }
76
+
77
+ @Test
78
+ fun `parseStreamTitle does not split on dash without surrounding spaces`() {
79
+ // "U-Turn" should NOT be split — Shoutcast convention uses " - " (spaces).
80
+ val (title, artist) = StreamMetadataExtractor.parseStreamTitle("U-Turn")
81
+ assertEquals("U-Turn", title)
82
+ assertNull(artist)
83
+ }
84
+
85
+ // ---- extract: synthetic IcyInfo entries ----
86
+
87
+ @Test
88
+ fun `extract from IcyInfo with Artist - Title splits both fields`() {
89
+ val info = IcyInfo(ByteArray(0), "Daft Punk - Get Lucky", null)
90
+ val event = StreamMetadataExtractor.extract(Metadata(info))
91
+
92
+ assertNotNull(event)
93
+ assertEquals("Get Lucky", event!!.title)
94
+ assertEquals("Daft Punk", event.artist)
95
+ assertNull(event.albumTitle)
96
+ assertNull(event.artworkUri)
97
+ assertNull(event.genre)
98
+ }
99
+
100
+ @Test
101
+ fun `extract from IcyInfo with single title leaves artist null`() {
102
+ val info = IcyInfo(ByteArray(0), "Morning Talk Show", null)
103
+ val event = StreamMetadataExtractor.extract(Metadata(info))
104
+
105
+ assertNotNull(event)
106
+ assertEquals("Morning Talk Show", event!!.title)
107
+ assertNull(event.artist)
108
+ }
109
+
110
+ @Test
111
+ fun `extract from IcyInfo with null title returns null event`() {
112
+ val info = IcyInfo(ByteArray(0), null, null)
113
+ val event = StreamMetadataExtractor.extract(Metadata(info))
114
+ assertNull(event)
115
+ }
116
+
117
+ @Test
118
+ fun `extract from empty Metadata returns null`() {
119
+ val event = StreamMetadataExtractor.extract(Metadata())
120
+ assertNull(event)
121
+ }
122
+
123
+ @Test
124
+ fun `extract maps IcyInfo url into artworkUri`() {
125
+ val info = IcyInfo(ByteArray(0), "Artist - Song", "https://example.com/cover.jpg")
126
+ val event = StreamMetadataExtractor.extract(Metadata(info))
127
+
128
+ assertNotNull(event)
129
+ assertEquals("https://example.com/cover.jpg", event!!.artworkUri)
130
+ }
131
+
132
+ @Test
133
+ fun `extract ignores empty IcyInfo url`() {
134
+ val info = IcyInfo(ByteArray(0), "Artist - Song", "")
135
+ val event = StreamMetadataExtractor.extract(Metadata(info))
136
+
137
+ assertNotNull(event)
138
+ assertNull(event!!.artworkUri)
139
+ }
140
+
141
+ // ---- extract: combined IcyInfo + IcyHeaders ----
142
+
143
+ @Test
144
+ fun `extract pulls genre from IcyHeaders alongside IcyInfo title`() {
145
+ // ProgressiveMediaPeriod emits IcyHeaders + IcyInfo together on first metadata
146
+ // for radio.co / klubradio.hu (icy-genre header from the response).
147
+ val info = IcyInfo(ByteArray(0), "Radiohead - No Surprises", null)
148
+ val headers = IcyHeaders(
149
+ /* bitrate = */ 128_000,
150
+ /* genre = */ "Indie Rock",
151
+ /* name = */ "Test Radio",
152
+ /* url = */ null,
153
+ /* isPublic = */ true,
154
+ /* metadataInterval = */ 16_000,
155
+ )
156
+ val event = StreamMetadataExtractor.extract(Metadata(info, headers))
157
+
158
+ assertNotNull(event)
159
+ assertEquals("No Surprises", event!!.title)
160
+ assertEquals("Radiohead", event.artist)
161
+ assertEquals("Indie Rock", event.genre)
162
+ }
163
+
164
+ // ---- extract: ID3 frames (covers podcasts / MP3 streams w/ embedded ID3) ----
165
+
166
+ @Test
167
+ fun `extract from ID3 frames sets title and artist without splitting`() {
168
+ // TIT2 = title, TPE1 = lead artist. When both arrive populated, the
169
+ // Shoutcast " - " heuristic must NOT split the title.
170
+ val title = TextInformationFrame("TIT2", null, ImmutableList.of("Song - With Dash"))
171
+ val artist = TextInformationFrame("TPE1", null, ImmutableList.of("Artist Name"))
172
+ val event = StreamMetadataExtractor.extract(Metadata(title, artist))
173
+
174
+ assertNotNull(event)
175
+ assertEquals("Song - With Dash", event!!.title)
176
+ assertEquals("Artist Name", event.artist)
177
+ }
178
+
179
+ // ---- extract: real captured ICY payloads from the reported streams ----
180
+ //
181
+ // The strings below are NOT illustrative examples — they are the literal
182
+ // bytes returned by the two stations the original bug report links to,
183
+ // captured live from production endpoints.
184
+ //
185
+ // Capture procedure (reproducible from `scripts/capture_icy.py`):
186
+ // $ scripts/capture_icy.py https://streamer.radio.co/s51a438b13/listen out.txt
187
+ // $ scripts/capture_icy.py https://stream.klubradio.hu:8443/ out.txt
188
+ // Both servers respond with `icy-metaint: 16000` and emit Shoutcast-style
189
+ // `StreamTitle='…';` blocks. NUL padding to the 16-byte block boundary has
190
+ // been stripped (matching IcyDecoder's behavior on the real device).
191
+
192
+ @Test
193
+ fun `extract real radio_co track-change payload (Sonia Odisho - Eked D Bayet)`() {
194
+ // Captured 2026-05-20 from https://streamer.radio.co/s51a438b13/listen
195
+ // Raw bytes (hex): 53 74 72 65 61 6d 54 69 74 6c 65 3d 27 53 6f 6e 69 61 …
196
+ val event = decodeAndExtract("StreamTitle='Sonia Odisho - Eked D Bayet';")
197
+ assertNotNull(event)
198
+ assertEquals("Eked D Bayet", event!!.title)
199
+ assertEquals("Sonia Odisho", event.artist)
200
+ assertNull(event.artworkUri)
201
+ }
202
+
203
+ @Test
204
+ fun `extract real radio_co between-songs empty StreamTitle`() {
205
+ // Same source, captured immediately before the track-change block above.
206
+ // Servers commonly emit `StreamTitle='';` during silence/transitions —
207
+ // the extractor must NOT synthesize a bogus event in that case.
208
+ val event = decodeAndExtract("StreamTitle='';")
209
+ assertNull(event)
210
+ }
211
+
212
+ @Test
213
+ fun `extract real klubradio_hu talk-segment empty StreamTitle`() {
214
+ // Captured 2026-05-20 from https://stream.klubradio.hu:8443/ (redirects
215
+ // to hu-stream03.klubradio.hu:8443/bpstream). The Hungarian talk station
216
+ // sends ICY metadata frames but with empty StreamTitle during talk
217
+ // segments — over a 60s window only this empty payload was observed.
218
+ val event = decodeAndExtract("StreamTitle='';")
219
+ assertNull(event)
220
+ }
221
+
222
+ @Test
223
+ fun `extract handles UTF-8 multibyte chars in real-world StreamTitle`() {
224
+ // Both endpoints serve UTF-8; while the captured radio.co block above
225
+ // was pure ASCII, exercising the high-byte path is critical because
226
+ // IcyDecoder falls back to ISO-8859-1 on UTF-8 decode failure (would
227
+ // mangle Hungarian/Icelandic/etc characters).
228
+ val event = decodeAndExtract("StreamTitle='Sigur Rós - Hoppípolla';")
229
+ assertNotNull(event)
230
+ assertEquals("Hoppípolla", event!!.title)
231
+ assertEquals("Sigur Rós", event.artist)
232
+ }
233
+
234
+ // ---- helpers ----
235
+
236
+ /**
237
+ * Drives the real [IcyDecoder] used by ExoPlayer's [ProgressiveMediaPeriod]
238
+ * with the given Shoutcast metadata string, then runs the result through
239
+ * [StreamMetadataExtractor]. This exercises the same code path live streams
240
+ * hit at runtime.
241
+ */
242
+ private fun decodeAndExtract(icyString: String): MetadataReceivedEvent? {
243
+ val bytes = icyString.toByteArray(Charsets.UTF_8)
244
+ val buffer = MetadataInputBuffer().apply {
245
+ data = ByteBuffer.allocate(bytes.size).apply {
246
+ put(bytes)
247
+ flip()
248
+ }
249
+ }
250
+ val decoded = IcyDecoder().decode(buffer)
251
+ assertNotNull("IcyDecoder should produce a Metadata bundle", decoded)
252
+ return StreamMetadataExtractor.extract(decoded!!)
253
+ }
254
+ }