react-native-queue-player 2.0.0 → 2.0.1

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 (28) hide show
  1. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +9 -1
  2. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +1 -1
  3. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaResponseCheck.kt +156 -0
  4. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaValidatingDataSource.kt +165 -0
  5. package/android/src/main/java/com/margelo/nitro/queueplayer/MimeCapturingDataSource.kt +18 -9
  6. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +10 -2
  7. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +149 -8
  8. package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +60 -0
  9. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaResponseCheckTest.kt +163 -0
  10. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaValidatingDataSourceTest.kt +189 -0
  11. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +34 -0
  12. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +86 -0
  13. package/ios/CrossfadeEngine.swift +12 -0
  14. package/ios/LookaheadCache.swift +27 -0
  15. package/ios/MediaResponseCheck.swift +122 -0
  16. package/ios/Tests/MediaResponseCheckTests.swift +128 -0
  17. package/ios/Tests/RetryRecoveryTests.swift +115 -0
  18. package/ios/Tests/TopUpWindowGateTests.swift +47 -0
  19. package/ios/Tests/TrackPlayerEndVerdictTests.swift +41 -0
  20. package/ios/TrackPlayer+EventsDispatch.swift +77 -3
  21. package/ios/TrackPlayer+Lifecycle.swift +53 -1
  22. package/ios/TrackPlayer+Queue.swift +11 -0
  23. package/ios/TrackPlayer+Recovery.swift +17 -0
  24. package/ios/TrackPlayer+Skip.swift +10 -0
  25. package/ios/TrackPlayer+Transport.swift +31 -26
  26. package/ios/TrackPlayer+Window.swift +32 -0
  27. package/ios/TrackPlayer.swift +26 -0
  28. package/package.json +1 -1
@@ -0,0 +1,189 @@
1
+ package com.margelo.nitro.queueplayer
2
+
3
+ import android.net.Uri
4
+ import androidx.media3.common.PlaybackException
5
+ import androidx.media3.common.util.UnstableApi
6
+ import androidx.media3.datasource.DataSource
7
+ import androidx.media3.datasource.DataSpec
8
+ import androidx.media3.datasource.HttpDataSource
9
+ import androidx.media3.datasource.TransferListener
10
+ import org.junit.Assert.assertArrayEquals
11
+ import org.junit.Assert.assertEquals
12
+ import org.junit.Assert.assertTrue
13
+ import org.junit.Assert.fail
14
+ import org.junit.Test
15
+ import org.junit.runner.RunWith
16
+ import org.robolectric.RobolectricTestRunner
17
+ import org.robolectric.annotation.Config
18
+
19
+ /**
20
+ * A response that is a document rather than media fails before any of its bytes
21
+ * are returned, so neither cache write path — the prefetcher's `CacheWriter` nor
22
+ * the playback read path's own sink — ever sees them.
23
+ *
24
+ * `android.net.Uri` is a framework type, so this runs under Robolectric like
25
+ * every other test here that parses one.
26
+ */
27
+ @RunWith(RobolectricTestRunner::class)
28
+ @Config(sdk = [34])
29
+ @UnstableApi
30
+ class MediaValidatingDataSourceTest {
31
+
32
+ /** An HTTP source that answers with fixed headers and a fixed body. */
33
+ private class FakeHttpSource(
34
+ private val body: ByteArray,
35
+ private val contentType: String?,
36
+ /** Bytes to hand back per read, so a body arriving in dribs is covered. */
37
+ private val chunk: Int = Int.MAX_VALUE
38
+ ) : HttpDataSource {
39
+ private var position = 0
40
+ var readCount = 0
41
+ private set
42
+
43
+ override fun open(dataSpec: DataSpec): Long {
44
+ position = dataSpec.position.toInt()
45
+ return (body.size - position).toLong()
46
+ }
47
+
48
+ override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
49
+ readCount += 1
50
+ if (position >= body.size) return -1
51
+ val take = minOf(length, chunk, body.size - position)
52
+ body.copyInto(buffer, offset, position, position + take)
53
+ position += take
54
+ return take
55
+ }
56
+
57
+ override fun getUri(): Uri = Uri.parse("https://example.com/track")
58
+ override fun close() {}
59
+ override fun addTransferListener(transferListener: TransferListener) {}
60
+ override fun getResponseHeaders(): Map<String, List<String>> =
61
+ if (contentType == null) emptyMap() else mapOf("Content-Type" to listOf(contentType))
62
+
63
+ override fun setRequestProperty(name: String, value: String) {}
64
+ override fun clearRequestProperty(name: String) {}
65
+ override fun clearAllRequestProperties() {}
66
+ override fun getResponseCode(): Int = 200
67
+ }
68
+
69
+ /** A non-HTTP source, which carries no declared type and is passed through. */
70
+ private class FakeLocalSource(private val body: ByteArray) : DataSource {
71
+ private var position = 0
72
+ override fun open(dataSpec: DataSpec): Long = body.size.toLong()
73
+ override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
74
+ if (position >= body.size) return -1
75
+ val take = minOf(length, body.size - position)
76
+ body.copyInto(buffer, offset, position, position + take)
77
+ position += take
78
+ return take
79
+ }
80
+ override fun getUri(): Uri = Uri.parse("file:///track.mp3")
81
+ override fun close() {}
82
+ override fun addTransferListener(transferListener: TransferListener) {}
83
+ }
84
+
85
+ private val xmlEnvelope =
86
+ """<api-response status="failed"><error code="70"/></api-response>""".toByteArray()
87
+ private val mp3 = byteArrayOf(0x49, 0x44, 0x33, 0x04, 0, 0, 0, 0) + ByteArray(120)
88
+
89
+ private fun openAndRead(
90
+ source: DataSource,
91
+ position: Long = 0L,
92
+ uri: String = "https://example.com/track"
93
+ ): ByteArray {
94
+ source.open(DataSpec.Builder().setUri(uri).setPosition(position).build())
95
+ val out = ArrayList<Byte>()
96
+ val buffer = ByteArray(32)
97
+ while (true) {
98
+ val read = source.read(buffer, 0, buffer.size)
99
+ if (read == -1) break
100
+ for (i in 0 until read) out.add(buffer[i])
101
+ }
102
+ return out.toByteArray()
103
+ }
104
+
105
+ @Test
106
+ fun aDeclaredDocumentFailsBeforeAnythingIsRead() {
107
+ val upstream = FakeHttpSource(xmlEnvelope, "application/xml")
108
+ val source = MediaValidatingDataSource(upstream)
109
+ try {
110
+ source.open(DataSpec(Uri.parse("https://example.com/track")))
111
+ fail("a declared document must not open")
112
+ } catch (e: HttpDataSource.HttpDataSourceException) {
113
+ assertEquals(PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE, e.reason)
114
+ }
115
+ assertEquals("nothing may be read from a refused response", 0, upstream.readCount)
116
+ }
117
+
118
+ @Test
119
+ fun anUndeclaredMarkupBodyFailsOnTheFirstRead() {
120
+ val source = MediaValidatingDataSource(FakeHttpSource(xmlEnvelope, null))
121
+ source.open(DataSpec(Uri.parse("https://example.com/track")))
122
+ try {
123
+ source.read(ByteArray(32), 0, 32)
124
+ fail("a markup body must not be returned")
125
+ } catch (e: HttpDataSource.HttpDataSourceException) {
126
+ assertEquals(PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE, e.reason)
127
+ }
128
+ }
129
+
130
+ @Test
131
+ fun plainTextWithNoContainerSignatureFailsOnTheFirstRead() {
132
+ val source = MediaValidatingDataSource(FakeHttpSource("not found".toByteArray(), "text/plain"))
133
+ source.open(DataSpec(Uri.parse("https://example.com/track")))
134
+ try {
135
+ source.read(ByteArray(32), 0, 32)
136
+ fail("text with no container signature must not be returned")
137
+ } catch (e: HttpDataSource.HttpDataSourceException) {
138
+ assertEquals(PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE, e.reason)
139
+ }
140
+ }
141
+
142
+ @Test
143
+ fun mediaIsPassedThroughWhole() {
144
+ val source = MediaValidatingDataSource(FakeHttpSource(mp3, "audio/mpeg"))
145
+ assertArrayEquals("every byte reaches the caller unchanged", mp3, openAndRead(source))
146
+ }
147
+
148
+ @Test
149
+ fun mediaArrivingInSmallChunksIsStillPassedThroughWhole() {
150
+ val source = MediaValidatingDataSource(FakeHttpSource(mp3, "audio/mpeg", chunk = 3))
151
+ assertArrayEquals(mp3, openAndRead(source))
152
+ }
153
+
154
+ @Test
155
+ fun aRangedRequestIsJudgedOnItsDeclaredTypeOnly() {
156
+ // The served window opens with `<`, so the leading-byte rule would refuse it
157
+ // if it were applied. A body read from an offset says nothing about the
158
+ // resource's first bytes, so it is not.
159
+ val markupAtOffset = "....<api-response/>".toByteArray()
160
+ val ranged = MediaValidatingDataSource(
161
+ FakeHttpSource(markupAtOffset, "application/octet-stream")
162
+ )
163
+ val read = openAndRead(ranged, position = 4L)
164
+ assertTrue("a partial body is not judged by its first byte", read.isNotEmpty())
165
+
166
+ // The same bytes as the whole resource are refused — which is what shows the
167
+ // acceptance above turns on the range and not on the bytes.
168
+ val whole = MediaValidatingDataSource(
169
+ FakeHttpSource("<api-response/>".toByteArray(), "application/octet-stream")
170
+ )
171
+ whole.open(DataSpec(Uri.parse("https://example.com/track")))
172
+ try {
173
+ whole.read(ByteArray(32), 0, 32)
174
+ fail("the same markup read from the start of the resource must be refused")
175
+ } catch (e: HttpDataSource.HttpDataSourceException) {
176
+ assertEquals(PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE, e.reason)
177
+ }
178
+ }
179
+
180
+ @Test
181
+ fun aLocalSourceIsPassedThroughUnjudged() {
182
+ val source = MediaValidatingDataSource(FakeLocalSource(xmlEnvelope))
183
+ assertArrayEquals(
184
+ "a file:// source is not fetched from a server and is not the case this guards",
185
+ xmlEnvelope,
186
+ openAndRead(source, uri = "file:///track.mp3")
187
+ )
188
+ }
189
+ }
@@ -182,6 +182,40 @@ class TrackPlayerEventsTest {
182
182
  assertEquals("nothing reaches a removed subscriber", 1, fired)
183
183
  }
184
184
 
185
+ /**
186
+ * A source that answered with something that is not media is a format
187
+ * problem, not a reachability one: it was reached, and a retry cannot change
188
+ * what it sends. Classing it transient would spend an automatic attempt on a
189
+ * body that can never become audio.
190
+ */
191
+ @Test
192
+ fun aNonMediaResponseClassifiesAsAFormatFailureAndIsFatal() {
193
+ val invalidContentType = PlaybackException(
194
+ "msg", null,
195
+ PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE
196
+ )
197
+ assertEquals(
198
+ PlaybackErrorCode.FILE_FORMAT_NOT_RECOGNIZED,
199
+ PlaybackErrorMapping.classify(invalidContentType)
200
+ )
201
+ assertFalse(
202
+ PlaybackErrorMapping.isTransient(PlaybackErrorCode.FILE_FORMAT_NOT_RECOGNIZED)
203
+ )
204
+ }
205
+
206
+ /** A source that is genuinely missing stays a reachability failure. */
207
+ @Test
208
+ fun aMissingFileStillClassifiesAsUnreachable() {
209
+ val notFound = PlaybackException(
210
+ "msg", null,
211
+ PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND
212
+ )
213
+ assertEquals(
214
+ PlaybackErrorCode.SOURCE_UNREACHABLE,
215
+ PlaybackErrorMapping.classify(notFound)
216
+ )
217
+ }
218
+
185
219
  // --- PlaybackErrorMapping.isTransient classification
186
220
  //
187
221
  // `PlaybackErrorMapping.isTransient` operates on the lib-
@@ -232,6 +232,92 @@ class TrackPlayerLifecycleTest {
232
232
  "boom", null,
233
233
  androidx.media3.common.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED)
234
234
 
235
+ /**
236
+ * Under crossfade the standby leg prerolls the next track, so a broken one
237
+ * fails while the leading track is still playing and is surfaced there. The
238
+ * engine then seats it and it fails again as the current track — the listener
239
+ * asked for one thing and must be told once.
240
+ */
241
+ @Test
242
+ fun `a track reported while it was prerolling does not report again when it is seated`() {
243
+ seedThreeTrackQueue(autoRetries = 0.0)
244
+
245
+ var reported = 0
246
+ val dispose = player.onError { reported += 1 }
247
+
248
+ // The standby leg prerolling track 1 fails while track 0 is playing.
249
+ player.onError(serviceHandle.engine, fatalFailure(), 1)
250
+ assertEquals("the listener is told their next track is broken", 1, reported)
251
+
252
+ // The fade begins, so track 1 is the one being heard, and it fails again as
253
+ // the current track.
254
+ player.onCrossfadeBegin(serviceHandle.engine, 1)
255
+ assertEquals("the fade seats the track that was prerolling", 1, player.currentTrackIndex)
256
+ player.onError(serviceHandle.engine, fatalFailure(), 1)
257
+ dispose()
258
+
259
+ assertEquals("and not told a second time about the same track", 1, reported)
260
+ }
261
+
262
+ /**
263
+ * A shuffle renumbers every position, so a record against the pre-shuffle
264
+ * current index names a different track afterwards. Nothing is carried.
265
+ */
266
+ @Test
267
+ fun `a shuffle leaves no track marked as stopped`() {
268
+ seedThreeTrackQueue(autoRetries = 0.0)
269
+ player.onError(serviceHandle.engine, fatalFailure(), 0)
270
+
271
+ player.shuffleQueueInternal()
272
+
273
+ assertEquals("a shuffle seats position 0", 0, player.currentTrackIndex)
274
+ // A stop standing against the current track silences every other track's
275
+ // failure, so a report for one of them is what shows the set is empty.
276
+ var reported = 0
277
+ val dispose = player.onError { reported += 1 }
278
+ player.onError(serviceHandle.engine, fatalFailure(), 1)
279
+ dispose()
280
+
281
+ assertEquals("no track is holding the queue after a shuffle", 1, reported)
282
+ }
283
+
284
+ /**
285
+ * `-1` means "no track", so a failure that carries no index — an engine that
286
+ * reports without one, or a queue that has been cleared — must not enter the
287
+ * set. A member equal to `currentTrackIndex` is what "stopped" means, and at
288
+ * `-1` that would read as stopped and silence every later failure.
289
+ */
290
+ @Test
291
+ fun `a failure with no track does not mark the queue as stopped`() {
292
+ player.configureInternal(config(autoRetries = 0.0), context())
293
+ player.setQueueInternal(emptyList())
294
+ assertEquals("an empty queue seats no track", -1, player.currentTrackIndex)
295
+
296
+ // An engine that reports without an index — AirPlay does — while the queue
297
+ // is empty. There is no position to record.
298
+ player.onError(serviceHandle.engine, fatalFailure())
299
+
300
+ // The add empties the set and restores the current track's entry, so with
301
+ // -1 a member that restore is what carries it forward. It leaves the index
302
+ // at -1 by design, which is what keeps it a member.
303
+ player.addToQueueInternal(
304
+ (0 until 2).map { track("https://example.com/$it.mp3") }, insertBefore = 0)
305
+ assertEquals("an add into an empty queue seats nothing", -1, player.currentTrackIndex)
306
+
307
+ var reported = 0
308
+ val dispose = player.onError { reported += 1 }
309
+ player.onError(serviceHandle.engine, fatalFailure(), 1)
310
+ dispose()
311
+
312
+ assertEquals(
313
+ "a track's failure still reaches the listener — nothing is holding the queue",
314
+ 1, reported)
315
+ }
316
+
317
+ private fun fatalFailure() = androidx.media3.common.PlaybackException(
318
+ "boom", null,
319
+ androidx.media3.common.PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE)
320
+
235
321
  private fun seedThreeTrackQueue(autoRetries: Double? = null) {
236
322
  player.configureInternal(config(autoRetries = autoRetries), context())
237
323
  player.setQueueInternal((0 until 3).map { track("https://example.com/$it.mp3") })
@@ -1235,6 +1235,18 @@ final class CrossfadeEngine: PlaybackEngine {
1235
1235
  /// standby to leading.
1236
1236
  private func runFade(durationSeconds: TimeInterval) {
1237
1237
  if released { return }
1238
+ // One envelope per boundary. The arm chain that reaches here is
1239
+ // asynchronous — a standby-ready wait, then a `tracks` key load, then a hop
1240
+ // back to the owner queue — and every step of it checks `standbyArmed`
1241
+ // only, so a boundary whose chain is entered more than once arrives here
1242
+ // more than once. Each arrival would schedule another fifty sub-ramps onto
1243
+ // the same `AVMutableAudioMixInputParameters` (`fadeParams` reuses the
1244
+ // leg's existing one so its audio tap survives the fade), and those windows
1245
+ // overlap the ones already on it: `setVolumeRamp` answers an overlapping
1246
+ // window by raising, which is not catchable from Swift and takes the
1247
+ // process with it. The envelope is also simply wrong once it is scheduled
1248
+ // twice, crash or no crash.
1249
+ if fadeInFlight { return }
1238
1250
  guard let leading = leadingPlayer.currentItem,
1239
1251
  let standby = standbyPlayer.currentItem else { return }
1240
1252
 
@@ -519,6 +519,18 @@ public final class LookaheadCache: @unchecked Sendable {
519
519
  )
520
520
  }
521
521
 
522
+ /// Enough of a body to carry any container signature the media check reads.
523
+ private static let mediaCheckHeadBytes = 64
524
+
525
+ /// The first `bytes` of a file, or empty when it cannot be read. An
526
+ /// unreadable head is not itself a rejection — the size check above has
527
+ /// already established the file is there.
528
+ private static func head(ofFileAt url: URL, bytes: Int) -> Data {
529
+ guard let handle = try? FileHandle(forReadingFrom: url) else { return Data() }
530
+ defer { try? handle.close() }
531
+ return (try? handle.read(upToCount: bytes)) ?? Data()
532
+ }
533
+
522
534
  /// Remove a rejected download's temp file. Cleanup failures are logged
523
535
  /// rather than thrown — the caller is already failing the download, and a
524
536
  /// leaked temp file should be diagnosable without masking that reason.
@@ -599,6 +611,17 @@ public final class LookaheadCache: @unchecked Sendable {
599
611
  }
600
612
 
601
613
  let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type")
614
+ // A 2xx of the right length can still be a server's error envelope rather
615
+ // than a track. Committing one puts a permanent non-audio file behind a
616
+ // "fully cached" entry, which outlives the server being well again.
617
+ if let rejection = MediaResponseCheck.rejection(
618
+ contentType: contentType,
619
+ head: Self.head(ofFileAt: tempLocation, bytes: Self.mediaCheckHeadBytes),
620
+ headIsStartOfResource: request.value(forHTTPHeaderField: "Range") == nil
621
+ ) {
622
+ discardTemp(at: tempLocation, reason: "non-media body")
623
+ throw LookaheadCacheError.nonMediaBody(rejection)
624
+ }
602
625
  return (tempLocation, sizeBytes, contentType)
603
626
  }
604
627
 
@@ -951,6 +974,10 @@ public enum LookaheadCacheError: Error {
951
974
  /// The downloaded file's size could not be read, so the truncation check
952
975
  /// cannot run; the file is discarded rather than committed unchecked.
953
976
  case sizeUnreadable
977
+ /// The body is a document rather than media — typically a server reporting
978
+ /// an API-level failure as a `200` carrying an XML or JSON envelope.
979
+ /// Committing it would serve that envelope as the track on every later play.
980
+ case nonMediaBody(MediaResponseCheck.Rejection)
954
981
  }
955
982
 
956
983
  /// Abstraction over `LookaheadCache.download` so the prefetcher
@@ -0,0 +1,122 @@
1
+ import Foundation
2
+
3
+ /// Decides whether an HTTP response that claims to carry a track can be
4
+ /// believed, from its `Content-Type` and the first bytes of its body.
5
+ ///
6
+ /// A server that answers an API-level failure with `200` and an XML or JSON
7
+ /// envelope is the case this exists for: media servers commonly report
8
+ /// errors that way by design, and a proxy answering with an HTML page does the
9
+ /// same. Such a body passes every length and status check, so without this it
10
+ /// is committed to the cache as a complete track and served from disk on every
11
+ /// later play — the track stays broken after the server is well again.
12
+ ///
13
+ /// The rules identify what is **not** media rather than demanding proof of what
14
+ /// is. The two mistakes are not equal: a false rejection costs a prefetch and
15
+ /// the track still plays from its origin, while a false acceptance breaks the
16
+ /// track until the cache is cleared by hand.
17
+ ///
18
+ /// Mirrored rule for rule by `MediaResponseCheck.kt` on Android.
19
+ public enum MediaResponseCheck {
20
+
21
+ /// Why a response was refused. Each case carries what was judged so a
22
+ /// caller's log names the response rather than just the verdict.
23
+ public enum Rejection: Equatable {
24
+ /// The declared type is a markup or JSON document, which no audio
25
+ /// container is.
26
+ case markupContentType(String)
27
+ /// The declared type is text of some other kind and the body carries no
28
+ /// recognised container signature. `text/plain` is not refused on the
29
+ /// label alone — media servers do mislabel audio — so the body is given
30
+ /// the chance to prove itself.
31
+ case textWithoutMediaSignature(String)
32
+ /// No usable declared type, and the body begins as a markup or JSON
33
+ /// document.
34
+ case markupBody
35
+ }
36
+
37
+ /// Types that are documents, never audio. Narrow on purpose:
38
+ /// `application/octet-stream` and `text/plain` are missing because servers
39
+ /// serve real audio under both.
40
+ private static let markupTypes: Set<String> = [
41
+ "text/html", "application/xhtml+xml",
42
+ "application/xml", "text/xml",
43
+ "application/json", "text/json",
44
+ ]
45
+
46
+ /// The verdict for a response, or nil to accept it.
47
+ ///
48
+ /// `head` is the first bytes of the body — 64 is plenty for every signature
49
+ /// below. `headIsStartOfResource` is false when the request carried a
50
+ /// `Range`, and then only the declared type is judged: a body that begins
51
+ /// part way into a file says nothing about the file's first bytes.
52
+ public static func rejection(
53
+ contentType: String?, head: Data, headIsStartOfResource: Bool
54
+ ) -> Rejection? {
55
+ let declared = normalised(contentType)
56
+ if let declared, markupTypes.contains(declared) {
57
+ return .markupContentType(declared)
58
+ }
59
+ guard headIsStartOfResource else { return nil }
60
+ let body = droppingByteOrderMark(head)
61
+ if let declared, declared.hasPrefix("text/") {
62
+ return hasMediaSignature(body) ? nil : .textWithoutMediaSignature(declared)
63
+ }
64
+ return beginsAsMarkup(body) ? .markupBody : nil
65
+ }
66
+
67
+ /// Lowercased type with any `; charset=…` parameter and surrounding space
68
+ /// removed. Nil for a missing or empty header.
69
+ private static func normalised(_ contentType: String?) -> String? {
70
+ guard let raw = contentType, !raw.isEmpty else { return nil }
71
+ let head = raw.prefix(while: { $0 != ";" })
72
+ let trimmed = String(head).trimmingCharacters(in: .whitespaces).lowercased()
73
+ return trimmed.isEmpty ? nil : trimmed
74
+ }
75
+
76
+ /// Drop a byte-order mark so the checks below read the first real byte. A
77
+ /// UTF-16 mark matters twice over: `FF FE` also satisfies the MPEG frame
78
+ /// sync pattern, so a UTF-16 document would otherwise read as audio.
79
+ private static func droppingByteOrderMark(_ head: Data) -> Data {
80
+ let bytes = [UInt8](head)
81
+ if bytes.count >= 3, bytes[0] == 0xEF, bytes[1] == 0xBB, bytes[2] == 0xBF {
82
+ return head.dropFirst(3)
83
+ }
84
+ if bytes.count >= 2,
85
+ (bytes[0] == 0xFF && bytes[1] == 0xFE) || (bytes[0] == 0xFE && bytes[1] == 0xFF) {
86
+ return head.dropFirst(2)
87
+ }
88
+ return head
89
+ }
90
+
91
+ /// Whether the body opens with the magic bytes of a container either
92
+ /// platform plays, or with an HLS playlist's own first line.
93
+ private static func hasMediaSignature(_ body: Data) -> Bool {
94
+ let bytes = [UInt8](body)
95
+ guard !bytes.isEmpty else { return false }
96
+ // MPEG audio frame sync: eleven set bits across the first two bytes. ADTS
97
+ // AAC shares it.
98
+ if bytes.count >= 2, bytes[0] == 0xFF, bytes[1] & 0xE0 == 0xE0 { return true }
99
+ let prefixes = ["ID3", "fLaC", "OggS", "RIFF", "FORM", "caff", "#!AMR", "#EXTM3U"]
100
+ if prefixes.contains(where: { starts(bytes, with: $0) }) { return true }
101
+ // ISO base media (MP4 / M4A / ALAC): a box length, then the type.
102
+ if bytes.count >= 8, starts(Array(bytes.dropFirst(4)), with: "ftyp") { return true }
103
+ return false
104
+ }
105
+
106
+ /// Whether the body opens as an XML / HTML document or a JSON value. No
107
+ /// container above begins with either character.
108
+ private static func beginsAsMarkup(_ body: Data) -> Bool {
109
+ guard let first = [UInt8](body).first(where: { !isAsciiSpace($0) }) else { return false }
110
+ return first == UInt8(ascii: "<") || first == UInt8(ascii: "{")
111
+ }
112
+
113
+ private static func isAsciiSpace(_ byte: UInt8) -> Bool {
114
+ byte == 0x20 || byte == 0x09 || byte == 0x0A || byte == 0x0D
115
+ }
116
+
117
+ private static func starts(_ bytes: [UInt8], with ascii: String) -> Bool {
118
+ let pattern = [UInt8](ascii.utf8)
119
+ guard bytes.count >= pattern.count else { return false }
120
+ return Array(bytes.prefix(pattern.count)) == pattern
121
+ }
122
+ }
@@ -0,0 +1,128 @@
1
+ import XCTest
2
+ @testable import QueuePlayer
3
+
4
+ /// What the cache will and will not believe is a track, judged from a
5
+ /// response's declared type and the first bytes of its body.
6
+ final class MediaResponseCheckTests: XCTestCase {
7
+
8
+ private let xmlEnvelope = Data("""
9
+ <api-response status="failed"><error code="70"/></api-response>
10
+ """.utf8)
11
+ private let mp3 = Data([0x49, 0x44, 0x33, 0x04, 0x00, 0x00, 0x00, 0x00]) // "ID3"
12
+
13
+ private func rejection(
14
+ _ contentType: String?, _ body: Data, whole: Bool = true
15
+ ) -> MediaResponseCheck.Rejection? {
16
+ MediaResponseCheck.rejection(
17
+ contentType: contentType, head: body, headIsStartOfResource: whole)
18
+ }
19
+
20
+ // MARK: - A declared document is refused whatever its body
21
+
22
+ func testMarkupAndJsonContentTypesAreRefused() {
23
+ for type in [
24
+ "application/xml", "text/xml", "text/html",
25
+ "application/xhtml+xml", "application/json", "text/json",
26
+ ] {
27
+ XCTAssertEqual(
28
+ rejection(type, xmlEnvelope), .markupContentType(type),
29
+ "\(type) is a document type")
30
+ }
31
+ }
32
+
33
+ func testTheDeclaredTypeIsReadWithoutItsParameters() {
34
+ XCTAssertEqual(
35
+ rejection("application/xml; charset=utf-8", xmlEnvelope),
36
+ .markupContentType("application/xml"))
37
+ XCTAssertEqual(rejection("APPLICATION/XML", xmlEnvelope), .markupContentType("application/xml"))
38
+ }
39
+
40
+ /// A ranged request answers from an arbitrary offset, so only the declared
41
+ /// type can be judged — but that much still holds.
42
+ func testADeclaredDocumentIsRefusedOnAPartialBodyToo() {
43
+ XCTAssertEqual(
44
+ rejection("application/xml", Data([0x3C, 0x00]), whole: false),
45
+ .markupContentType("application/xml"))
46
+ }
47
+
48
+ func testAPartialBodyIsNotJudgedByItsLeadingBytes() {
49
+ // The same markup, under a type that is not itself a document, so only the
50
+ // leading-byte rule can decide: refused as a whole resource, accepted as a
51
+ // range, which is what separates the two inputs.
52
+ let markup = Data("<api-response/>".utf8)
53
+ XCTAssertEqual(
54
+ rejection("application/octet-stream", markup),
55
+ .markupBody)
56
+ XCTAssertNil(
57
+ rejection("application/octet-stream", markup, whole: false),
58
+ "a body that begins part way into a file says nothing about its first bytes")
59
+ }
60
+
61
+ // MARK: - Plain text has to prove itself
62
+
63
+ func testTextWithoutAContainerSignatureIsRefused() {
64
+ XCTAssertEqual(
65
+ rejection("text/plain", Data("not found".utf8)),
66
+ .textWithoutMediaSignature("text/plain"))
67
+ }
68
+
69
+ func testTextCarryingAContainerSignatureIsAccepted() {
70
+ XCTAssertNil(
71
+ rejection("text/plain", mp3),
72
+ "a server mislabelling audio as text does not make it a document")
73
+ }
74
+
75
+ func testAPlaylistDeclaredAsTextIsAccepted() {
76
+ XCTAssertNil(rejection("text/plain", Data("#EXTM3U\n#EXT-X-VERSION:3".utf8)))
77
+ }
78
+
79
+ // MARK: - An unusable declared type falls to the body
80
+
81
+ func testAMarkupBodyIsRefusedWhenNothingUsableIsDeclared() {
82
+ XCTAssertEqual(rejection(nil, xmlEnvelope), .markupBody)
83
+ XCTAssertEqual(rejection("application/octet-stream", xmlEnvelope), .markupBody)
84
+ XCTAssertEqual(rejection(nil, Data("<!DOCTYPE html><html>".utf8)), .markupBody)
85
+ XCTAssertEqual(rejection(nil, Data("{\"error\":\"not found\"}".utf8)), .markupBody)
86
+ }
87
+
88
+ func testLeadingSpaceAndAByteOrderMarkDoNotHideAMarkupBody() {
89
+ XCTAssertEqual(rejection(nil, Data("\n <?xml version=\"1.0\"?>".utf8)), .markupBody)
90
+ XCTAssertEqual(
91
+ rejection(nil, Data([0xEF, 0xBB, 0xBF]) + Data("<error/>".utf8)), .markupBody)
92
+ // `FF FE` also satisfies the MPEG frame sync, so a UTF-16 document would
93
+ // read as audio if the mark were not dropped first.
94
+ XCTAssertEqual(
95
+ rejection(nil, Data([0xFF, 0xFE]) + Data("<e/>".utf8)), .markupBody)
96
+ }
97
+
98
+ // MARK: - Real media is accepted, however it is labelled
99
+
100
+ func testRecognisedContainersAreAccepted() {
101
+ let bodies: [String: [UInt8]] = [
102
+ "ID3": [0x49, 0x44, 0x33, 0x04, 0, 0, 0, 0],
103
+ "MPEG frame sync": [0xFF, 0xFB, 0x90, 0x00, 0, 0, 0, 0],
104
+ "ADTS AAC": [0xFF, 0xF1, 0x50, 0x80, 0, 0, 0, 0],
105
+ "FLAC": [0x66, 0x4C, 0x61, 0x43, 0, 0, 0, 0],
106
+ "Ogg": [0x4F, 0x67, 0x67, 0x53, 0, 0, 0, 0],
107
+ "WAV": [0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0],
108
+ "AIFF": [0x46, 0x4F, 0x52, 0x4D, 0, 0, 0, 0],
109
+ "CAF": [0x63, 0x61, 0x66, 0x66, 0, 0, 0, 0],
110
+ "MP4": [0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70],
111
+ ]
112
+ for (name, bytes) in bodies {
113
+ XCTAssertNil(rejection("text/plain", Data(bytes)), "\(name) is media")
114
+ XCTAssertNil(rejection(nil, Data(bytes)), "\(name) is media")
115
+ }
116
+ }
117
+
118
+ func testAnUndeclaredOrOpaqueBinaryBodyIsAccepted() {
119
+ let binary = Data([0x00, 0x01, 0x02, 0x03, 0x04])
120
+ XCTAssertNil(rejection(nil, binary), "an unrecognised container is not a document")
121
+ XCTAssertNil(rejection("application/octet-stream", binary))
122
+ XCTAssertNil(rejection("audio/mpeg", binary))
123
+ }
124
+
125
+ func testAnEmptyBodyIsNotJudged() {
126
+ XCTAssertNil(rejection(nil, Data()))
127
+ }
128
+ }