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.
- package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCache.kt +9 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaResponseCheck.kt +156 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaValidatingDataSource.kt +165 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MimeCapturingDataSource.kt +18 -9
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackErrorMapping.kt +10 -2
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +149 -8
- package/android/src/test/java/com/margelo/nitro/queueplayer/LookaheadCacheTest.kt +60 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaResponseCheckTest.kt +163 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaValidatingDataSourceTest.kt +189 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +34 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +86 -0
- package/ios/CrossfadeEngine.swift +12 -0
- package/ios/LookaheadCache.swift +27 -0
- package/ios/MediaResponseCheck.swift +122 -0
- package/ios/Tests/MediaResponseCheckTests.swift +128 -0
- package/ios/Tests/RetryRecoveryTests.swift +115 -0
- package/ios/Tests/TopUpWindowGateTests.swift +47 -0
- package/ios/Tests/TrackPlayerEndVerdictTests.swift +41 -0
- package/ios/TrackPlayer+EventsDispatch.swift +77 -3
- package/ios/TrackPlayer+Lifecycle.swift +53 -1
- package/ios/TrackPlayer+Queue.swift +11 -0
- package/ios/TrackPlayer+Recovery.swift +17 -0
- package/ios/TrackPlayer+Skip.swift +10 -0
- package/ios/TrackPlayer+Transport.swift +31 -26
- package/ios/TrackPlayer+Window.swift +32 -0
- package/ios/TrackPlayer.swift +26 -0
- 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
|
|
package/ios/LookaheadCache.swift
CHANGED
|
@@ -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
|
+
}
|