@rntp/player 5.0.0-beta.4 → 5.0.0-beta.5
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/build.gradle +7 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/SleepTimerController.kt +128 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +40 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +99 -87
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
- package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
- package/android/src/test/resources/robolectric.properties +1 -0
- package/ios/TrackPlayer.swift +46 -101
- package/ios/TrackPlayerBridge.mm +2 -0
- package/ios/player/AVPlayerEngine.swift +46 -32
- package/ios/player/AudioCache.swift +34 -0
- package/ios/player/AudioPlayer.swift +36 -21
- package/ios/player/CacheProxyServer.swift +429 -0
- package/ios/player/DownloadCoordinator.swift +242 -0
- package/ios/player/Preloader.swift +21 -90
- package/ios/player/SleepTimerController.swift +147 -0
- package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
- package/ios/tests/AudioPlayerTests.swift +6 -0
- package/ios/tests/CacheProxyServerTests.swift +403 -0
- package/ios/tests/DownloadCoordinatorTests.swift +197 -0
- package/ios/tests/LocalAudioServer.swift +171 -0
- package/ios/tests/MockPlayerEngine.swift +1 -0
- package/ios/tests/QueueManagerTests.swift +6 -0
- package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
- package/ios/tests/SleepTimerTests.swift +70 -0
- package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
- package/lib/commonjs/audio.js +19 -0
- package/lib/commonjs/audio.js.map +1 -1
- package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
- package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
- package/lib/module/NativeTrackPlayer.js.map +1 -1
- package/lib/module/audio.js +17 -0
- package/lib/module/audio.js.map +1 -1
- package/lib/module/interfaces/PlayerConfig.js +1 -1
- package/lib/module/interfaces/PlayerConfig.js.map +1 -1
- package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
- package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
- package/lib/typescript/src/audio.d.ts +12 -1
- package/lib/typescript/src/audio.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
- package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts +19 -2
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/NativeTrackPlayer.ts +4 -0
- package/src/audio.ts +18 -0
- package/src/interfaces/MediaItem.ts +4 -1
- package/src/interfaces/PlayerConfig.ts +22 -3
- package/ios/player/CachingResourceLoader.swift +0 -273
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright (c) Double Symmetry GmbH
|
|
3
|
+
// Commercial use requires a license. See https://rntp.dev/pricing
|
|
4
|
+
//
|
|
5
|
+
|
|
6
|
+
import XCTest
|
|
7
|
+
@testable import PlayerCore
|
|
8
|
+
|
|
9
|
+
final class CacheProxyServerTests: XCTestCase {
|
|
10
|
+
private var cacheDir: URL!
|
|
11
|
+
private var cache: AudioCache!
|
|
12
|
+
private var coordinator: DownloadCoordinator!
|
|
13
|
+
private var proxy: CacheProxyServer!
|
|
14
|
+
|
|
15
|
+
override func setUp() {
|
|
16
|
+
super.setUp()
|
|
17
|
+
cacheDir = FileManager.default.temporaryDirectory
|
|
18
|
+
.appendingPathComponent("CacheProxyServerTests-\(UUID().uuidString)", isDirectory: true)
|
|
19
|
+
cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
|
|
20
|
+
coordinator = DownloadCoordinator(cache: cache)
|
|
21
|
+
proxy = try! CacheProxyServer(coordinator: coordinator, cache: cache)
|
|
22
|
+
proxy.start()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
override func tearDown() {
|
|
26
|
+
proxy.stop()
|
|
27
|
+
coordinator.cancelAll()
|
|
28
|
+
proxy = nil
|
|
29
|
+
coordinator = nil
|
|
30
|
+
try? FileManager.default.removeItem(at: cacheDir)
|
|
31
|
+
super.tearDown()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// MARK: - Helpers
|
|
35
|
+
|
|
36
|
+
/// Perform a synchronous HTTP GET through the proxy and return (data, response).
|
|
37
|
+
private func fetch(_ url: URL, timeout: TimeInterval = 10.0) throws -> (Data, HTTPURLResponse) {
|
|
38
|
+
let expectation = self.expectation(description: "fetch \(url)")
|
|
39
|
+
var resultData: Data?
|
|
40
|
+
var resultResponse: HTTPURLResponse?
|
|
41
|
+
var resultError: Error?
|
|
42
|
+
|
|
43
|
+
let task = URLSession.shared.dataTask(with: url) { data, response, error in
|
|
44
|
+
resultData = data
|
|
45
|
+
resultResponse = response as? HTTPURLResponse
|
|
46
|
+
resultError = error
|
|
47
|
+
expectation.fulfill()
|
|
48
|
+
}
|
|
49
|
+
task.resume()
|
|
50
|
+
wait(for: [expectation], timeout: timeout)
|
|
51
|
+
|
|
52
|
+
if let error = resultError { throw error }
|
|
53
|
+
return (resultData ?? Data(), resultResponse!)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// MARK: - 1. Cache miss with Content-Length
|
|
57
|
+
|
|
58
|
+
func testCacheMissWithContentLength() throws {
|
|
59
|
+
let testData = Data(repeating: 0xAA, count: 4096)
|
|
60
|
+
let server = try LocalAudioServer(
|
|
61
|
+
audioData: testData,
|
|
62
|
+
options: .init(includeContentLength: true, contentType: "audio/mpeg")
|
|
63
|
+
)
|
|
64
|
+
server.start()
|
|
65
|
+
defer { server.stop() }
|
|
66
|
+
|
|
67
|
+
let upstreamURL = server.url
|
|
68
|
+
let proxyURL = proxy.proxyURL(for: upstreamURL, headers: nil)
|
|
69
|
+
|
|
70
|
+
let (data, response) = try fetch(proxyURL)
|
|
71
|
+
|
|
72
|
+
XCTAssertEqual(response.statusCode, 200)
|
|
73
|
+
XCTAssertEqual(data, testData, "Proxy should return the full audio data")
|
|
74
|
+
|
|
75
|
+
// Verify it was cached
|
|
76
|
+
let key = cache.cacheKey(for: upstreamURL)
|
|
77
|
+
// Give a moment for the download completion handler to finalize
|
|
78
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
79
|
+
XCTAssertEqual(cache.cachedBytes(for: key), Int64(testData.count))
|
|
80
|
+
XCTAssertTrue(cache.isFullyCached(key: key))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// MARK: - 2. Cache miss without Content-Length
|
|
84
|
+
|
|
85
|
+
func testCacheMissWithoutContentLength() throws {
|
|
86
|
+
let testData = Data(repeating: 0xBB, count: 2048)
|
|
87
|
+
let server = try LocalAudioServer(
|
|
88
|
+
audioData: testData,
|
|
89
|
+
options: .init(includeContentLength: false, contentType: "audio/aac")
|
|
90
|
+
)
|
|
91
|
+
server.start()
|
|
92
|
+
defer { server.stop() }
|
|
93
|
+
|
|
94
|
+
let upstreamURL = server.url
|
|
95
|
+
let proxyURL = proxy.proxyURL(for: upstreamURL, headers: nil)
|
|
96
|
+
|
|
97
|
+
let (data, response) = try fetch(proxyURL)
|
|
98
|
+
|
|
99
|
+
XCTAssertEqual(response.statusCode, 200)
|
|
100
|
+
XCTAssertEqual(data, testData)
|
|
101
|
+
|
|
102
|
+
// Content length should be finalized after download completes
|
|
103
|
+
let key = cache.cacheKey(for: upstreamURL)
|
|
104
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
105
|
+
let info = cache.contentInfo(for: key)
|
|
106
|
+
XCTAssertNotNil(info)
|
|
107
|
+
XCTAssertEqual(info?.contentLength, Int64(testData.count),
|
|
108
|
+
"Content length should be finalized after download")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// MARK: - 3. Cache hit
|
|
112
|
+
|
|
113
|
+
func testCacheHitServesFromDisk() throws {
|
|
114
|
+
let testData = Data(repeating: 0xCC, count: 1024)
|
|
115
|
+
let fakeURL = URL(string: "https://example.com/cached-track.mp3")!
|
|
116
|
+
let key = cache.cacheKey(for: fakeURL)
|
|
117
|
+
|
|
118
|
+
// Pre-populate cache
|
|
119
|
+
let info = AudioCache.ContentInfo(
|
|
120
|
+
contentType: "audio/mpeg",
|
|
121
|
+
contentLength: Int64(testData.count),
|
|
122
|
+
isByteRangeAccessSupported: false
|
|
123
|
+
)
|
|
124
|
+
cache.storeContentInfo(info, for: key, url: fakeURL)
|
|
125
|
+
cache.appendData(testData, for: key)
|
|
126
|
+
|
|
127
|
+
let proxyURL = proxy.proxyURL(for: fakeURL, headers: nil)
|
|
128
|
+
let (data, response) = try fetch(proxyURL)
|
|
129
|
+
|
|
130
|
+
XCTAssertEqual(response.statusCode, 200)
|
|
131
|
+
XCTAssertEqual(data, testData)
|
|
132
|
+
// Cache hit should include Content-Length
|
|
133
|
+
let contentLength = response.value(forHTTPHeaderField: "Content-Length")
|
|
134
|
+
XCTAssertEqual(contentLength, "\(testData.count)")
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// MARK: - 4. Partial cache + resume
|
|
138
|
+
|
|
139
|
+
func testPartialCacheResumesDownload() throws {
|
|
140
|
+
let testData = Data(repeating: 0xDD, count: 4000)
|
|
141
|
+
let server = try LocalAudioServer(
|
|
142
|
+
audioData: testData,
|
|
143
|
+
options: .init(includeContentLength: true, contentType: "audio/mpeg")
|
|
144
|
+
)
|
|
145
|
+
server.start()
|
|
146
|
+
defer { server.stop() }
|
|
147
|
+
|
|
148
|
+
let upstreamURL = server.url
|
|
149
|
+
let key = cache.cacheKey(for: upstreamURL)
|
|
150
|
+
|
|
151
|
+
// Pre-populate partial cache (first 1000 bytes)
|
|
152
|
+
let partialData = testData.prefix(1000)
|
|
153
|
+
let info = AudioCache.ContentInfo(
|
|
154
|
+
contentType: "audio/mpeg",
|
|
155
|
+
contentLength: Int64(testData.count),
|
|
156
|
+
isByteRangeAccessSupported: false
|
|
157
|
+
)
|
|
158
|
+
cache.storeContentInfo(info, for: key, url: upstreamURL)
|
|
159
|
+
cache.appendData(partialData, for: key)
|
|
160
|
+
|
|
161
|
+
let proxyURL = proxy.proxyURL(for: upstreamURL, headers: nil)
|
|
162
|
+
let (data, response) = try fetch(proxyURL)
|
|
163
|
+
|
|
164
|
+
XCTAssertEqual(response.statusCode, 200)
|
|
165
|
+
// The proxy should serve the full data (partial cached + remainder from upstream).
|
|
166
|
+
// Note: LocalAudioServer doesn't support Range requests, so it returns the full file.
|
|
167
|
+
// The DownloadCoordinator appends only new data beyond what's cached.
|
|
168
|
+
// However, since LocalAudioServer returns 200 (not 206), the coordinator will
|
|
169
|
+
// append the full response. The proxy streams whatever is in the cache.
|
|
170
|
+
// The important thing is the client gets data and no error occurs.
|
|
171
|
+
XCTAssertGreaterThanOrEqual(data.count, testData.count)
|
|
172
|
+
|
|
173
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
174
|
+
XCTAssertGreaterThanOrEqual(cache.cachedBytes(for: key), Int64(testData.count))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// MARK: - 5. Upstream failure
|
|
178
|
+
|
|
179
|
+
func testUpstreamFailureReturns502() throws {
|
|
180
|
+
// Use a URL that will fail (nothing listening on this port)
|
|
181
|
+
let badURL = URL(string: "http://localhost:1/nonexistent.mp3")!
|
|
182
|
+
let proxyURL = proxy.proxyURL(for: badURL, headers: nil)
|
|
183
|
+
|
|
184
|
+
let (_, response) = try fetch(proxyURL)
|
|
185
|
+
|
|
186
|
+
XCTAssertEqual(response.statusCode, 502, "Should return 502 for upstream failure")
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// MARK: - 6. Preload then play (shared download)
|
|
190
|
+
|
|
191
|
+
func testPreloadThenPlaySharesDownload() throws {
|
|
192
|
+
let testData = Data(repeating: 0xEE, count: 8000)
|
|
193
|
+
let server = try LocalAudioServer(
|
|
194
|
+
audioData: testData,
|
|
195
|
+
options: .init(includeContentLength: true, contentType: "audio/mpeg")
|
|
196
|
+
)
|
|
197
|
+
server.start()
|
|
198
|
+
defer { server.stop() }
|
|
199
|
+
|
|
200
|
+
let upstreamURL = server.url
|
|
201
|
+
|
|
202
|
+
// Start a preload via the coordinator directly
|
|
203
|
+
let preloadDone = expectation(description: "preload done")
|
|
204
|
+
coordinator.download(url: upstreamURL, headers: nil) { result in
|
|
205
|
+
if case .success = result { preloadDone.fulfill() }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Immediately make a proxy request — it should attach to the same download
|
|
209
|
+
let proxyURL = proxy.proxyURL(for: upstreamURL, headers: nil)
|
|
210
|
+
let (data, response) = try fetch(proxyURL)
|
|
211
|
+
|
|
212
|
+
wait(for: [preloadDone], timeout: 10.0)
|
|
213
|
+
|
|
214
|
+
XCTAssertEqual(response.statusCode, 200)
|
|
215
|
+
XCTAssertEqual(data, testData)
|
|
216
|
+
|
|
217
|
+
// Only one download should have been active
|
|
218
|
+
let key = cache.cacheKey(for: upstreamURL)
|
|
219
|
+
XCTAssertTrue(cache.isFullyCached(key: key))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// MARK: - 7. Large cached file serves completely via serveFromCache
|
|
223
|
+
|
|
224
|
+
func testLargeCacheHitServesCompletely() throws {
|
|
225
|
+
// Simulate a fully-cached podcast file (50MB).
|
|
226
|
+
// This tests the serveFromCache 64KB chunked send path.
|
|
227
|
+
let size = 50 * 1024 * 1024
|
|
228
|
+
let testData = Data(repeating: 0x66, count: size)
|
|
229
|
+
let url = URL(string: "https://example.com/big-podcast.mp3")!
|
|
230
|
+
let key = cache.cacheKey(for: url)
|
|
231
|
+
|
|
232
|
+
// Pre-populate cache.
|
|
233
|
+
cache = AudioCache(maxSizeBytes: Int64(size + 1024 * 1024), directory: cacheDir)
|
|
234
|
+
let info = AudioCache.ContentInfo(
|
|
235
|
+
contentType: "audio/mpeg",
|
|
236
|
+
contentLength: Int64(size),
|
|
237
|
+
isByteRangeAccessSupported: true
|
|
238
|
+
)
|
|
239
|
+
cache.storeContentInfo(info, for: key, url: url)
|
|
240
|
+
cache.appendData(testData, for: key)
|
|
241
|
+
|
|
242
|
+
// Re-create proxy with the larger cache.
|
|
243
|
+
proxy.stop()
|
|
244
|
+
coordinator = DownloadCoordinator(cache: cache)
|
|
245
|
+
proxy = try! CacheProxyServer(coordinator: coordinator, cache: cache)
|
|
246
|
+
proxy.start()
|
|
247
|
+
|
|
248
|
+
let proxyURL = proxy.proxyURL(for: url, headers: nil)
|
|
249
|
+
let (data, response) = try fetch(proxyURL, timeout: 30.0)
|
|
250
|
+
|
|
251
|
+
XCTAssertEqual(response.statusCode, 200)
|
|
252
|
+
XCTAssertEqual(data.count, size,
|
|
253
|
+
"All \(size) bytes should be served from cache (no Content-Length mismatch)")
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// MARK: - 8. Large file over slow network streams completely
|
|
257
|
+
|
|
258
|
+
func testLargeFileOverSlowNetworkStreamsCompletely() throws {
|
|
259
|
+
// 5MB file with simulated slow network (64KB chunks, 10ms delay each).
|
|
260
|
+
// This is ~780 chunks taking ~8 seconds — enough to expose races
|
|
261
|
+
// between the coordinator writing to cache and the proxy reading it.
|
|
262
|
+
let size = 5 * 1024 * 1024
|
|
263
|
+
let testData = Data(repeating: 0x55, count: size)
|
|
264
|
+
let upstream = try LocalAudioServer(
|
|
265
|
+
audioData: testData,
|
|
266
|
+
options: .init(includeContentLength: true, contentType: "audio/mpeg", simulateSlowNetwork: true)
|
|
267
|
+
)
|
|
268
|
+
upstream.start()
|
|
269
|
+
defer { upstream.stop() }
|
|
270
|
+
|
|
271
|
+
let upstreamURL = upstream.url
|
|
272
|
+
let proxyURL = proxy.proxyURL(for: upstreamURL, headers: nil)
|
|
273
|
+
let (data, response) = try fetch(proxyURL, timeout: 30.0)
|
|
274
|
+
|
|
275
|
+
XCTAssertEqual(response.statusCode, 200)
|
|
276
|
+
XCTAssertEqual(data.count, size,
|
|
277
|
+
"All \(size) bytes should be delivered over slow connection")
|
|
278
|
+
|
|
279
|
+
let key = cache.cacheKey(for: upstreamURL)
|
|
280
|
+
XCTAssertTrue(cache.isFullyCached(key: key))
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// MARK: - 9. Large file streams completely from upstream
|
|
284
|
+
|
|
285
|
+
func testLargeFileStreamsCompletely() throws {
|
|
286
|
+
// Simulate a podcast-sized file (10MB — large enough to expose
|
|
287
|
+
// chunking race conditions).
|
|
288
|
+
let testData = Data(repeating: 0x77, count: 10 * 1024 * 1024)
|
|
289
|
+
let upstream = try LocalAudioServer(
|
|
290
|
+
audioData: testData,
|
|
291
|
+
options: .init(includeContentLength: true, contentType: "audio/mpeg")
|
|
292
|
+
)
|
|
293
|
+
upstream.start()
|
|
294
|
+
defer { upstream.stop() }
|
|
295
|
+
|
|
296
|
+
let upstreamURL = upstream.url
|
|
297
|
+
let proxyURL = proxy.proxyURL(for: upstreamURL, headers: nil)
|
|
298
|
+
let (data, response) = try fetch(proxyURL, timeout: 30.0)
|
|
299
|
+
|
|
300
|
+
XCTAssertEqual(response.statusCode, 200)
|
|
301
|
+
XCTAssertEqual(data.count, testData.count,
|
|
302
|
+
"All bytes should be delivered (no Content-Length mismatch)")
|
|
303
|
+
|
|
304
|
+
let key = cache.cacheKey(for: upstreamURL)
|
|
305
|
+
XCTAssertTrue(cache.isFullyCached(key: key))
|
|
306
|
+
XCTAssertEqual(cache.cachedBytes(for: key), Int64(testData.count))
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// MARK: - 8. Cache hit after full download serves correctly
|
|
310
|
+
|
|
311
|
+
func testCacheHitAfterDownload() throws {
|
|
312
|
+
let testData = Data(repeating: 0x88, count: 512 * 1024)
|
|
313
|
+
let upstream = try LocalAudioServer(
|
|
314
|
+
audioData: testData,
|
|
315
|
+
options: .init(includeContentLength: true, contentType: "audio/mpeg")
|
|
316
|
+
)
|
|
317
|
+
upstream.start()
|
|
318
|
+
|
|
319
|
+
let upstreamURL = upstream.url
|
|
320
|
+
let proxyURL = proxy.proxyURL(for: upstreamURL, headers: nil)
|
|
321
|
+
|
|
322
|
+
// First fetch — downloads and caches.
|
|
323
|
+
let (data1, _) = try fetch(proxyURL)
|
|
324
|
+
XCTAssertEqual(data1.count, testData.count)
|
|
325
|
+
|
|
326
|
+
// Stop upstream — second fetch must come from cache.
|
|
327
|
+
upstream.stop()
|
|
328
|
+
|
|
329
|
+
let (data2, response2) = try fetch(proxyURL)
|
|
330
|
+
XCTAssertEqual(response2.statusCode, 200)
|
|
331
|
+
XCTAssertEqual(data2.count, testData.count)
|
|
332
|
+
XCTAssertEqual(data2, testData, "Cache hit should serve identical data")
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// MARK: - 10. Preload then play serves from cache
|
|
336
|
+
|
|
337
|
+
func testPreloadThenPlayServesFromCache() throws {
|
|
338
|
+
let testData = Data(repeating: 0x99, count: 4096)
|
|
339
|
+
let upstream = try LocalAudioServer(
|
|
340
|
+
audioData: testData,
|
|
341
|
+
options: .init(includeContentLength: true, contentType: "audio/mpeg")
|
|
342
|
+
)
|
|
343
|
+
upstream.start()
|
|
344
|
+
defer { upstream.stop() }
|
|
345
|
+
|
|
346
|
+
let upstreamURL = upstream.url
|
|
347
|
+
let key = cache.cacheKey(for: upstreamURL)
|
|
348
|
+
|
|
349
|
+
// Preload via coordinator (simulating TrackPlayer.preload)
|
|
350
|
+
let preloadDone = expectation(description: "preload complete")
|
|
351
|
+
coordinator.download(url: upstreamURL, headers: nil, cacheKey: key) { _ in
|
|
352
|
+
preloadDone.fulfill()
|
|
353
|
+
}
|
|
354
|
+
wait(for: [preloadDone], timeout: 5.0)
|
|
355
|
+
|
|
356
|
+
XCTAssertTrue(cache.isFullyCached(key: key))
|
|
357
|
+
|
|
358
|
+
// Now fetch via proxy (simulating playback) — should be a cache hit
|
|
359
|
+
let proxyURL = proxy.proxyURL(for: upstreamURL, headers: nil)
|
|
360
|
+
let (data, response) = try fetch(proxyURL)
|
|
361
|
+
|
|
362
|
+
XCTAssertEqual(response.statusCode, 200)
|
|
363
|
+
XCTAssertEqual(data.count, testData.count)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// MARK: - 9. Range request on cache hit serves correct slice
|
|
367
|
+
|
|
368
|
+
func testRangeRequestOnCacheHit() throws {
|
|
369
|
+
let testData = Data((0..<1024).map { UInt8($0 % 256) })
|
|
370
|
+
let url = URL(string: "https://example.com/range-test.mp3")!
|
|
371
|
+
let key = cache.cacheKey(for: url)
|
|
372
|
+
|
|
373
|
+
// Pre-populate full cache.
|
|
374
|
+
let info = AudioCache.ContentInfo(
|
|
375
|
+
contentType: "audio/mpeg",
|
|
376
|
+
contentLength: Int64(testData.count),
|
|
377
|
+
isByteRangeAccessSupported: true
|
|
378
|
+
)
|
|
379
|
+
cache.storeContentInfo(info, for: key, url: url)
|
|
380
|
+
cache.appendData(testData, for: key)
|
|
381
|
+
|
|
382
|
+
// Request bytes 500-799.
|
|
383
|
+
let proxyURL = proxy.proxyURL(for: url, headers: nil)
|
|
384
|
+
var request = URLRequest(url: proxyURL)
|
|
385
|
+
request.setValue("bytes=500-799", forHTTPHeaderField: "Range")
|
|
386
|
+
|
|
387
|
+
let expectation = self.expectation(description: "range fetch")
|
|
388
|
+
var resultData: Data?
|
|
389
|
+
var resultResponse: HTTPURLResponse?
|
|
390
|
+
|
|
391
|
+
let task = URLSession.shared.dataTask(with: request) { data, response, _ in
|
|
392
|
+
resultData = data
|
|
393
|
+
resultResponse = response as? HTTPURLResponse
|
|
394
|
+
expectation.fulfill()
|
|
395
|
+
}
|
|
396
|
+
task.resume()
|
|
397
|
+
wait(for: [expectation], timeout: 5.0)
|
|
398
|
+
|
|
399
|
+
XCTAssertEqual(resultResponse?.statusCode, 206)
|
|
400
|
+
XCTAssertEqual(resultData?.count, 300)
|
|
401
|
+
XCTAssertEqual(resultData, testData.subdata(in: 500..<800))
|
|
402
|
+
}
|
|
403
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright (c) Double Symmetry GmbH
|
|
3
|
+
// Commercial use requires a license. See https://rntp.dev/pricing
|
|
4
|
+
//
|
|
5
|
+
|
|
6
|
+
import XCTest
|
|
7
|
+
@testable import PlayerCore
|
|
8
|
+
|
|
9
|
+
final class DownloadCoordinatorTests: XCTestCase {
|
|
10
|
+
private var cacheDir: URL!
|
|
11
|
+
private var cache: AudioCache!
|
|
12
|
+
private var coordinator: DownloadCoordinator!
|
|
13
|
+
|
|
14
|
+
override func setUp() {
|
|
15
|
+
super.setUp()
|
|
16
|
+
cacheDir = FileManager.default.temporaryDirectory
|
|
17
|
+
.appendingPathComponent("DownloadCoordinatorTests-\(UUID().uuidString)", isDirectory: true)
|
|
18
|
+
cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
|
|
19
|
+
coordinator = DownloadCoordinator(cache: cache)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
override func tearDown() {
|
|
23
|
+
coordinator = nil
|
|
24
|
+
try? FileManager.default.removeItem(at: cacheDir)
|
|
25
|
+
super.tearDown()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func testDownloadCachesDataAndFinalizesContentLength() throws {
|
|
29
|
+
let testData = Data(repeating: 0xAB, count: 1000)
|
|
30
|
+
let server = try LocalAudioServer(
|
|
31
|
+
audioData: testData,
|
|
32
|
+
options: .init(includeContentLength: true, contentType: "audio/mpeg")
|
|
33
|
+
)
|
|
34
|
+
server.start()
|
|
35
|
+
defer { server.stop() }
|
|
36
|
+
|
|
37
|
+
let url = server.url
|
|
38
|
+
let key = cache.cacheKey(for: url)
|
|
39
|
+
let done = expectation(description: "download complete")
|
|
40
|
+
|
|
41
|
+
coordinator.download(url: url, headers: nil) { result in
|
|
42
|
+
if case .success = result { done.fulfill() }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
wait(for: [done], timeout: 5.0)
|
|
46
|
+
|
|
47
|
+
XCTAssertEqual(cache.cachedBytes(for: key), Int64(testData.count))
|
|
48
|
+
let info = cache.contentInfo(for: key)
|
|
49
|
+
XCTAssertNotNil(info)
|
|
50
|
+
XCTAssertEqual(info?.contentLength, Int64(testData.count))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func testDownloadWithoutContentLengthFinalizesOnCompletion() throws {
|
|
54
|
+
let testData = Data(repeating: 0xCD, count: 500)
|
|
55
|
+
let server = try LocalAudioServer(
|
|
56
|
+
audioData: testData,
|
|
57
|
+
options: .init(includeContentLength: false, contentType: "audio/aac")
|
|
58
|
+
)
|
|
59
|
+
server.start()
|
|
60
|
+
defer { server.stop() }
|
|
61
|
+
|
|
62
|
+
let url = server.url
|
|
63
|
+
let key = cache.cacheKey(for: url)
|
|
64
|
+
let done = expectation(description: "download complete")
|
|
65
|
+
|
|
66
|
+
coordinator.download(url: url, headers: nil) { result in
|
|
67
|
+
if case .success = result { done.fulfill() }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
wait(for: [done], timeout: 5.0)
|
|
71
|
+
|
|
72
|
+
XCTAssertEqual(cache.cachedBytes(for: key), Int64(testData.count))
|
|
73
|
+
let info = cache.contentInfo(for: key)
|
|
74
|
+
XCTAssertNotNil(info)
|
|
75
|
+
XCTAssertEqual(info?.contentLength, Int64(testData.count),
|
|
76
|
+
"Content length should be finalized to actual byte count")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func testDuplicateDownloadReusesActiveTask() throws {
|
|
80
|
+
let testData = Data(repeating: 0xEF, count: 2000)
|
|
81
|
+
let server = try LocalAudioServer(
|
|
82
|
+
audioData: testData,
|
|
83
|
+
options: .init(includeContentLength: true, contentType: "audio/mpeg")
|
|
84
|
+
)
|
|
85
|
+
server.start()
|
|
86
|
+
defer { server.stop() }
|
|
87
|
+
|
|
88
|
+
let url = server.url
|
|
89
|
+
let key = cache.cacheKey(for: url)
|
|
90
|
+
|
|
91
|
+
let done1 = expectation(description: "first subscriber done")
|
|
92
|
+
let done2 = expectation(description: "second subscriber done")
|
|
93
|
+
|
|
94
|
+
coordinator.download(url: url, headers: nil) { _ in done1.fulfill() }
|
|
95
|
+
coordinator.download(url: url, headers: nil) { _ in done2.fulfill() }
|
|
96
|
+
|
|
97
|
+
wait(for: [done1, done2], timeout: 5.0)
|
|
98
|
+
|
|
99
|
+
// Data should only be cached once (not duplicated)
|
|
100
|
+
XCTAssertEqual(cache.cachedBytes(for: key), Int64(testData.count))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func testCancellingOneSubscriberDoesNotStopDownload() throws {
|
|
104
|
+
let testData = Data(repeating: 0x11, count: 1000)
|
|
105
|
+
let server = try LocalAudioServer(
|
|
106
|
+
audioData: testData,
|
|
107
|
+
options: .init(includeContentLength: true, contentType: "audio/mpeg")
|
|
108
|
+
)
|
|
109
|
+
server.start()
|
|
110
|
+
defer { server.stop() }
|
|
111
|
+
|
|
112
|
+
let url = server.url
|
|
113
|
+
let key = cache.cacheKey(for: url)
|
|
114
|
+
|
|
115
|
+
let done = expectation(description: "second subscriber done")
|
|
116
|
+
|
|
117
|
+
let token1 = coordinator.download(url: url, headers: nil) { _ in }
|
|
118
|
+
coordinator.download(url: url, headers: nil) { _ in done.fulfill() }
|
|
119
|
+
coordinator.cancelDownload(token: token1)
|
|
120
|
+
|
|
121
|
+
wait(for: [done], timeout: 5.0)
|
|
122
|
+
XCTAssertEqual(cache.cachedBytes(for: key), Int64(testData.count))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func testCancellingAllSubscribersCancelsDownload() throws {
|
|
126
|
+
let testData = Data(repeating: 0x22, count: 1000)
|
|
127
|
+
let server = try LocalAudioServer(
|
|
128
|
+
audioData: testData,
|
|
129
|
+
options: .init(includeContentLength: true, contentType: "audio/mpeg")
|
|
130
|
+
)
|
|
131
|
+
server.start()
|
|
132
|
+
defer { server.stop() }
|
|
133
|
+
|
|
134
|
+
let url = server.url
|
|
135
|
+
|
|
136
|
+
let token1 = coordinator.download(url: url, headers: nil) { _ in }
|
|
137
|
+
let token2 = coordinator.download(url: url, headers: nil) { _ in }
|
|
138
|
+
coordinator.cancelDownload(token: token1)
|
|
139
|
+
coordinator.cancelDownload(token: token2)
|
|
140
|
+
|
|
141
|
+
// Give time for cancellation to propagate
|
|
142
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
143
|
+
|
|
144
|
+
XCTAssertTrue(coordinator.activeDownloadCount == 0)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func testDownloadStopsAtMaxBytes() throws {
|
|
148
|
+
// Use 200 KB so the slow-network path sends multiple 64 KB chunks,
|
|
149
|
+
// giving the coordinator a chance to cancel mid-stream.
|
|
150
|
+
let totalBytes = 200 * 1024
|
|
151
|
+
let testData = Data(repeating: 0xBB, count: totalBytes)
|
|
152
|
+
let server = try LocalAudioServer(
|
|
153
|
+
audioData: testData,
|
|
154
|
+
options: .init(includeContentLength: true, contentType: "audio/mpeg", simulateSlowNetwork: true)
|
|
155
|
+
)
|
|
156
|
+
server.start()
|
|
157
|
+
defer { server.stop() }
|
|
158
|
+
|
|
159
|
+
let url = server.url
|
|
160
|
+
let key = cache.cacheKey(for: url)
|
|
161
|
+
// Stop after ~70 KB — well into the second 64 KB chunk but short of the full 200 KB.
|
|
162
|
+
let maxBytes: Int64 = 70 * 1024
|
|
163
|
+
let done = expectation(description: "download stopped at limit")
|
|
164
|
+
|
|
165
|
+
coordinator.download(url: url, headers: nil, cacheKey: key, maxBytes: maxBytes) { result in
|
|
166
|
+
if case .success = result { done.fulfill() }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
wait(for: [done], timeout: 10.0)
|
|
170
|
+
|
|
171
|
+
let cached = cache.cachedBytes(for: key)
|
|
172
|
+
// Should be at least maxBytes but not the full file.
|
|
173
|
+
// The coordinator checks after each data chunk, so it may slightly overshoot.
|
|
174
|
+
XCTAssertGreaterThanOrEqual(cached, maxBytes)
|
|
175
|
+
XCTAssertLessThan(cached, Int64(testData.count),
|
|
176
|
+
"Should not have downloaded the full file")
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func testFullyCachedURLSkipsDownload() throws {
|
|
180
|
+
let testData = Data(repeating: 0x33, count: 100)
|
|
181
|
+
let url = URL(string: "https://example.com/cached.mp3")!
|
|
182
|
+
let key = cache.cacheKey(for: url)
|
|
183
|
+
|
|
184
|
+
// Pre-populate cache
|
|
185
|
+
let info = AudioCache.ContentInfo(contentType: "audio/mpeg", contentLength: 100, isByteRangeAccessSupported: false)
|
|
186
|
+
cache.storeContentInfo(info, for: key, url: url)
|
|
187
|
+
cache.appendData(testData, for: key)
|
|
188
|
+
|
|
189
|
+
let done = expectation(description: "immediate completion")
|
|
190
|
+
coordinator.download(url: url, headers: nil) { result in
|
|
191
|
+
if case .success = result { done.fulfill() }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
wait(for: [done], timeout: 1.0)
|
|
195
|
+
XCTAssertEqual(coordinator.activeDownloadCount, 0, "No download should have started")
|
|
196
|
+
}
|
|
197
|
+
}
|