@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.
Files changed (56) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/src/main/java/com/doublesymmetry/trackplayer/SleepTimerController.kt +128 -0
  3. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +40 -0
  4. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +99 -87
  5. package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
  6. package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
  7. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
  8. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
  9. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
  10. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
  11. package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
  12. package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
  13. package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
  14. package/android/src/test/resources/robolectric.properties +1 -0
  15. package/ios/TrackPlayer.swift +46 -101
  16. package/ios/TrackPlayerBridge.mm +2 -0
  17. package/ios/player/AVPlayerEngine.swift +46 -32
  18. package/ios/player/AudioCache.swift +34 -0
  19. package/ios/player/AudioPlayer.swift +36 -21
  20. package/ios/player/CacheProxyServer.swift +429 -0
  21. package/ios/player/DownloadCoordinator.swift +242 -0
  22. package/ios/player/Preloader.swift +21 -90
  23. package/ios/player/SleepTimerController.swift +147 -0
  24. package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
  25. package/ios/tests/AudioPlayerTests.swift +6 -0
  26. package/ios/tests/CacheProxyServerTests.swift +403 -0
  27. package/ios/tests/DownloadCoordinatorTests.swift +197 -0
  28. package/ios/tests/LocalAudioServer.swift +171 -0
  29. package/ios/tests/MockPlayerEngine.swift +1 -0
  30. package/ios/tests/QueueManagerTests.swift +6 -0
  31. package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
  32. package/ios/tests/SleepTimerTests.swift +70 -0
  33. package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
  34. package/lib/commonjs/audio.js +19 -0
  35. package/lib/commonjs/audio.js.map +1 -1
  36. package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
  37. package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
  38. package/lib/module/NativeTrackPlayer.js.map +1 -1
  39. package/lib/module/audio.js +17 -0
  40. package/lib/module/audio.js.map +1 -1
  41. package/lib/module/interfaces/PlayerConfig.js +1 -1
  42. package/lib/module/interfaces/PlayerConfig.js.map +1 -1
  43. package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
  44. package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
  45. package/lib/typescript/src/audio.d.ts +12 -1
  46. package/lib/typescript/src/audio.d.ts.map +1 -1
  47. package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
  48. package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
  49. package/lib/typescript/src/interfaces/PlayerConfig.d.ts +19 -2
  50. package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
  51. package/package.json +4 -1
  52. package/src/NativeTrackPlayer.ts +4 -0
  53. package/src/audio.ts +18 -0
  54. package/src/interfaces/MediaItem.ts +4 -1
  55. package/src/interfaces/PlayerConfig.ts +22 -3
  56. package/ios/player/CachingResourceLoader.swift +0 -273
@@ -0,0 +1,429 @@
1
+ //
2
+ // Copyright (c) Double Symmetry GmbH
3
+ // Commercial use requires a license. See https://rntp.dev/pricing
4
+ //
5
+
6
+ import Foundation
7
+ import Network
8
+
9
+ /// An HTTP proxy server running on localhost that sits between AVPlayer and
10
+ /// the network. It serves fully-cached audio from disk and streams in-progress
11
+ /// downloads using chunked Transfer-Encoding.
12
+ final class CacheProxyServer {
13
+
14
+ private let coordinator: DownloadCoordinator
15
+ private let cache: AudioCache
16
+ private let listener: NWListener
17
+ private let serverQueue = DispatchQueue(label: "trackplayer.proxy-server")
18
+ private var connections: [NWConnection] = []
19
+
20
+ /// The TCP port the server is listening on. Available after `start()`.
21
+ var port: UInt16 {
22
+ listener.port?.rawValue ?? 0
23
+ }
24
+
25
+ init(coordinator: DownloadCoordinator, cache: AudioCache) throws {
26
+ self.coordinator = coordinator
27
+ self.cache = cache
28
+ self.listener = try NWListener(using: .tcp, on: .any)
29
+ }
30
+
31
+ // MARK: - Lifecycle
32
+
33
+ func start() {
34
+ let ready = DispatchSemaphore(value: 0)
35
+ listener.stateUpdateHandler = { state in
36
+ if case .ready = state { ready.signal() }
37
+ }
38
+ listener.newConnectionHandler = { [weak self] connection in
39
+ self?.acceptConnection(connection)
40
+ }
41
+ listener.start(queue: serverQueue)
42
+ ready.wait()
43
+ }
44
+
45
+ func stop() {
46
+ for conn in connections { conn.cancel() }
47
+ connections.removeAll()
48
+ listener.cancel()
49
+ }
50
+
51
+ // MARK: - URL Building
52
+
53
+ /// Build a proxy URL that encodes the upstream URL and optional headers
54
+ /// as query parameters.
55
+ ///
56
+ /// Format: `http://localhost:<port>/<cacheKey>?url=<encoded>&header_Key=Value`
57
+ func proxyURL(for url: URL, headers: [String: String]?) -> URL {
58
+ let key = cache.cacheKey(for: url)
59
+ let ext = url.pathExtension
60
+ var components = URLComponents()
61
+ components.scheme = "http"
62
+ components.host = "localhost"
63
+ components.port = Int(port)
64
+ components.path = ext.isEmpty ? "/\(key)" : "/\(key).\(ext)"
65
+
66
+ var queryItems = [URLQueryItem(name: "url", value: url.absoluteString)]
67
+ if let headers = headers {
68
+ for (k, v) in headers.sorted(by: { $0.key < $1.key }) {
69
+ queryItems.append(URLQueryItem(name: "header_\(k)", value: v))
70
+ }
71
+ }
72
+ components.queryItems = queryItems
73
+
74
+ return components.url!
75
+ }
76
+
77
+ // MARK: - Connection Handling
78
+
79
+ private func acceptConnection(_ connection: NWConnection) {
80
+ connections.append(connection)
81
+
82
+ connection.stateUpdateHandler = { [weak self] state in
83
+ switch state {
84
+ case .cancelled, .failed:
85
+ self?.connections.removeAll { $0 === connection }
86
+ default:
87
+ break
88
+ }
89
+ }
90
+
91
+ connection.start(queue: serverQueue)
92
+
93
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 16384) { [weak self] data, _, _, error in
94
+ guard let self = self, let data = data, error == nil else {
95
+ connection.cancel()
96
+ return
97
+ }
98
+ self.handleRawRequest(data, on: connection)
99
+ }
100
+ }
101
+
102
+ // MARK: - HTTP Request Parsing
103
+
104
+ private struct ParsedRequest {
105
+ let cacheKey: String
106
+ let upstreamURL: URL
107
+ let headers: [String: String]
108
+ /// Byte range from a `Range: bytes=N-` or `Range: bytes=N-M` header.
109
+ let rangeStart: Int64?
110
+ let rangeEnd: Int64?
111
+ }
112
+
113
+ private func parseRequest(_ data: Data) -> ParsedRequest? {
114
+ guard let requestString = String(data: data, encoding: .utf8) else { return nil }
115
+
116
+ // Parse the request line: GET /path?query HTTP/1.1
117
+ let lines = requestString.components(separatedBy: "\r\n")
118
+ guard let requestLine = lines.first else { return nil }
119
+ let parts = requestLine.split(separator: " ")
120
+ guard parts.count >= 2 else { return nil }
121
+
122
+ let rawPath = String(parts[1])
123
+ guard let components = URLComponents(string: rawPath) else { return nil }
124
+
125
+ // Cache key is the path component (strip leading / and file extension)
126
+ var cacheKey = String(components.path.dropFirst())
127
+ if let dotIdx = cacheKey.lastIndex(of: ".") {
128
+ cacheKey = String(cacheKey[cacheKey.startIndex..<dotIdx])
129
+ }
130
+ guard !cacheKey.isEmpty else { return nil }
131
+
132
+ // Extract query parameters
133
+ let queryItems = components.queryItems ?? []
134
+
135
+ guard let urlParam = queryItems.first(where: { $0.name == "url" })?.value,
136
+ let upstreamURL = URL(string: urlParam) else { return nil }
137
+
138
+ var headers: [String: String] = [:]
139
+ for item in queryItems where item.name.hasPrefix("header_") {
140
+ let headerKey = String(item.name.dropFirst("header_".count))
141
+ headers[headerKey] = item.value ?? ""
142
+ }
143
+
144
+ // Parse Range header from AVPlayer's request (e.g. "Range: bytes=N-" or "Range: bytes=N-M")
145
+ var rangeStart: Int64?
146
+ var rangeEnd: Int64?
147
+ for line in lines.dropFirst() {
148
+ let lower = line.lowercased()
149
+ guard lower.hasPrefix("range:") else { continue }
150
+ let value = line.dropFirst("range:".count).trimmingCharacters(in: .whitespaces)
151
+ guard value.hasPrefix("bytes=") else { break }
152
+ let byteRange = value.dropFirst("bytes=".count)
153
+ let parts = byteRange.components(separatedBy: "-")
154
+ if let startStr = parts.first, let start = Int64(startStr) {
155
+ rangeStart = start
156
+ }
157
+ if parts.count > 1, let endStr = parts.last, !endStr.isEmpty, let end = Int64(endStr) {
158
+ rangeEnd = end
159
+ }
160
+ break
161
+ }
162
+
163
+ return ParsedRequest(cacheKey: cacheKey, upstreamURL: upstreamURL, headers: headers, rangeStart: rangeStart, rangeEnd: rangeEnd)
164
+ }
165
+
166
+ // MARK: - Request Routing
167
+
168
+ private func handleRawRequest(_ data: Data, on connection: NWConnection) {
169
+ guard let request = parseRequest(data) else {
170
+ sendError(status: 400, message: "Bad Request", on: connection)
171
+ return
172
+ }
173
+
174
+ if cache.isFullyCached(key: request.cacheKey) {
175
+ serveFromCache(request: request, on: connection)
176
+ } else {
177
+ serveThroughCoordinator(request: request, on: connection)
178
+ }
179
+ }
180
+
181
+ // MARK: - Cache Hit (full)
182
+
183
+ private func serveFromCache(request: ParsedRequest, on connection: NWConnection) {
184
+ let key = request.cacheKey
185
+ guard let info = cache.contentInfo(for: key) else {
186
+ sendError(status: 500, message: "Cache read error", on: connection)
187
+ return
188
+ }
189
+
190
+ let totalLength = info.contentLength
191
+ let startByte = request.rangeStart ?? 0
192
+ let endByte = request.rangeEnd ?? (totalLength - 1)
193
+ let sliceLength = endByte - startByte + 1
194
+
195
+ var header: String
196
+ if request.rangeStart != nil {
197
+ header = "HTTP/1.1 206 Partial Content\r\n"
198
+ header += "Content-Type: \(info.contentType)\r\n"
199
+ header += "Content-Range: bytes \(startByte)-\(endByte)/\(totalLength)\r\n"
200
+ header += "Content-Length: \(sliceLength)\r\n"
201
+ header += "Accept-Ranges: bytes\r\n"
202
+ header += "Connection: close\r\n"
203
+ header += "\r\n"
204
+ } else {
205
+ header = "HTTP/1.1 200 OK\r\n"
206
+ header += "Content-Type: \(info.contentType)\r\n"
207
+ header += "Content-Length: \(totalLength)\r\n"
208
+ header += "Accept-Ranges: bytes\r\n"
209
+ header += "Connection: close\r\n"
210
+ header += "\r\n"
211
+ }
212
+
213
+ connection.send(content: Data(header.utf8), completion: .contentProcessed { _ in })
214
+
215
+ let chunkSize = 64 * 1024
216
+ var offset: Int64 = startByte
217
+ let serveEnd = endByte + 1
218
+
219
+ func sendNextChunk() {
220
+ guard offset < serveEnd else {
221
+ connection.cancel()
222
+ return
223
+ }
224
+ let length = min(chunkSize, Int(serveEnd - offset))
225
+ guard let chunk = self.cache.readData(for: key, offset: offset, length: length) else {
226
+ connection.cancel()
227
+ return
228
+ }
229
+ offset += Int64(chunk.count)
230
+ let isComplete = offset >= serveEnd
231
+ connection.send(content: chunk, completion: .contentProcessed { error in
232
+ if error != nil || isComplete {
233
+ connection.cancel()
234
+ } else {
235
+ sendNextChunk()
236
+ }
237
+ })
238
+ }
239
+
240
+ sendNextChunk()
241
+ }
242
+
243
+ // MARK: - Cache Miss / Partial — Stream via Coordinator
244
+
245
+ private func serveThroughCoordinator(request: ParsedRequest, on connection: NWConnection) {
246
+ let key = request.cacheKey
247
+ let headers = request.headers.isEmpty ? nil : request.headers
248
+ let rangeStart = request.rangeStart ?? 0
249
+
250
+ // Track how many cache bytes we've sent to this client (absolute offset)
251
+ var bytesSent: Int64 = rangeStart
252
+ // Whether we're using chunked transfer encoding (no Content-Length known)
253
+ var isChunked = false
254
+ // Whether we've sent the HTTP response header yet
255
+ var headerSent = false
256
+ // Set when a send fails (client disconnected), prevents further writes.
257
+ var connectionClosed = false
258
+ // Download state — set from coordinator completion on serverQueue.
259
+ var downloadFinished = false
260
+ var downloadResult: Result<Void, Error> = .success(())
261
+
262
+ // Try to send the HTTP response header once content info is available.
263
+ func trySendResponseHeader() {
264
+ guard !headerSent else { return }
265
+ guard let contentInfo = self.cache.contentInfo(for: key) else { return }
266
+ headerSent = true
267
+
268
+ let contentLength = contentInfo.contentLength
269
+ var header: String
270
+ if contentLength > 0 {
271
+ isChunked = false
272
+ if request.rangeStart != nil {
273
+ let endByte = request.rangeEnd ?? (contentLength - 1)
274
+ let responseLength = endByte - rangeStart + 1
275
+ header = "HTTP/1.1 206 Partial Content\r\n"
276
+ header += "Content-Type: \(contentInfo.contentType)\r\n"
277
+ header += "Content-Range: bytes \(rangeStart)-\(endByte)/\(contentLength)\r\n"
278
+ header += "Content-Length: \(responseLength)\r\n"
279
+ header += "Accept-Ranges: bytes\r\n"
280
+ header += "Connection: close\r\n"
281
+ header += "\r\n"
282
+ } else {
283
+ header = "HTTP/1.1 200 OK\r\n"
284
+ header += "Content-Type: \(contentInfo.contentType)\r\n"
285
+ header += "Content-Length: \(contentLength)\r\n"
286
+ header += "Accept-Ranges: bytes\r\n"
287
+ header += "Connection: close\r\n"
288
+ header += "\r\n"
289
+ }
290
+ } else {
291
+ isChunked = true
292
+ header = "HTTP/1.1 200 OK\r\n"
293
+ header += "Content-Type: \(contentInfo.contentType)\r\n"
294
+ header += "Transfer-Encoding: chunked\r\n"
295
+ header += "Connection: close\r\n"
296
+ header += "\r\n"
297
+ }
298
+
299
+ connection.send(content: Data(header.utf8), completion: .contentProcessed { error in
300
+ if error != nil { connectionClosed = true }
301
+ })
302
+ }
303
+
304
+ // Send ONE chunk, then schedule the next via completion callback.
305
+ // This ensures only one write is in flight at a time, preventing
306
+ // broken pipe spam when the client disconnects.
307
+ func sendNextChunk() {
308
+ guard !connectionClosed else { return }
309
+
310
+ // If the download failed before we sent headers, send an error response.
311
+ if downloadFinished, case .failure = downloadResult, !headerSent {
312
+ self.sendError(status: 502, message: "Bad Gateway", on: connection)
313
+ return
314
+ }
315
+
316
+ trySendResponseHeader()
317
+ guard headerSent else {
318
+ // Content info not ready yet — retry shortly.
319
+ self.serverQueue.asyncAfter(deadline: .now() + .milliseconds(5)) {
320
+ sendNextChunk()
321
+ }
322
+ return
323
+ }
324
+
325
+ let available = self.cache.cachedBytes(for: key)
326
+ if bytesSent < available {
327
+ let chunkSize = min(Int(available - bytesSent), 64 * 1024)
328
+ guard let chunk = self.cache.readData(for: key, offset: bytesSent, length: chunkSize) else {
329
+ // Read failed — close.
330
+ connection.cancel()
331
+ return
332
+ }
333
+
334
+ let dataToSend: Data
335
+ if isChunked {
336
+ var chunked = Data(String(chunk.count, radix: 16).utf8)
337
+ chunked.append(Data("\r\n".utf8))
338
+ chunked.append(chunk)
339
+ chunked.append(Data("\r\n".utf8))
340
+ dataToSend = chunked
341
+ } else {
342
+ dataToSend = chunk
343
+ }
344
+
345
+ bytesSent += Int64(chunk.count)
346
+ connection.send(content: dataToSend, completion: .contentProcessed { [weak self] error in
347
+ guard self != nil else { return }
348
+ if error != nil {
349
+ connectionClosed = true
350
+ connection.cancel()
351
+ return
352
+ }
353
+ // Send next chunk on the server queue.
354
+ self?.serverQueue.async { sendNextChunk() }
355
+ })
356
+ return
357
+ }
358
+
359
+ // No more cached data available right now.
360
+ if downloadFinished {
361
+ // Verify all bytes were sent (cache flush race).
362
+ if !isChunked, let info = self.cache.contentInfo(for: key),
363
+ info.contentLength > 0, bytesSent < info.contentLength {
364
+ // Retry after a brief delay.
365
+ self.serverQueue.asyncAfter(deadline: .now() + .milliseconds(10)) {
366
+ sendNextChunk()
367
+ }
368
+ return
369
+ }
370
+
371
+ // All done — close the connection.
372
+ switch downloadResult {
373
+ case .success:
374
+ if isChunked {
375
+ connection.send(content: Data("0\r\n\r\n".utf8), completion: .contentProcessed { _ in
376
+ connection.cancel()
377
+ })
378
+ } else {
379
+ connection.send(content: Data(), completion: .contentProcessed { _ in
380
+ connection.cancel()
381
+ })
382
+ }
383
+ case .failure:
384
+ if !headerSent {
385
+ self.sendError(status: 502, message: "Bad Gateway", on: connection)
386
+ } else {
387
+ connection.cancel()
388
+ }
389
+ }
390
+ return
391
+ }
392
+
393
+ // Download still in progress — wait for more data.
394
+ self.serverQueue.asyncAfter(deadline: .now() + .milliseconds(5)) {
395
+ sendNextChunk()
396
+ }
397
+ }
398
+
399
+ // Start the send chain.
400
+ sendNextChunk()
401
+
402
+ // Kick off the download — the coordinator stores contentInfo as soon
403
+ // as the upstream response headers arrive.
404
+ coordinator.download(url: request.upstreamURL, headers: headers, cacheKey: key) { [weak self] result in
405
+ self?.serverQueue.async {
406
+ downloadResult = result
407
+ downloadFinished = true
408
+ }
409
+ }
410
+ }
411
+
412
+ // MARK: - Error Response
413
+
414
+ private func sendError(status: Int, message: String, on connection: NWConnection) {
415
+ let body = message
416
+ var header = "HTTP/1.1 \(status) \(message)\r\n"
417
+ header += "Content-Type: text/plain\r\n"
418
+ header += "Content-Length: \(body.utf8.count)\r\n"
419
+ header += "Connection: close\r\n"
420
+ header += "\r\n"
421
+
422
+ var response = Data(header.utf8)
423
+ response.append(Data(body.utf8))
424
+
425
+ connection.send(content: response, completion: .contentProcessed { _ in
426
+ connection.cancel()
427
+ })
428
+ }
429
+ }
@@ -0,0 +1,242 @@
1
+ //
2
+ // Copyright (c) Double Symmetry GmbH
3
+ // Commercial use requires a license. See https://rntp.dev/pricing
4
+ //
5
+
6
+ import Foundation
7
+
8
+ /// Token returned by download() to allow cancellation of a specific subscriber.
9
+ struct DownloadToken: Equatable {
10
+ fileprivate let id: UUID = UUID()
11
+ fileprivate let cacheKey: String
12
+ }
13
+
14
+ /// Coordinates downloads for audio URLs. Deduplicates concurrent requests
15
+ /// for the same cache key so that preload → play transitions share a single
16
+ /// upstream connection.
17
+ final class DownloadCoordinator: NSObject {
18
+
19
+ private let cache: AudioCache
20
+ private let sessionQueue = DispatchQueue(label: "trackplayer.downloads")
21
+ private lazy var session: URLSession = {
22
+ let config = URLSessionConfiguration.default
23
+ let opQueue = OperationQueue()
24
+ opQueue.underlyingQueue = sessionQueue
25
+ opQueue.maxConcurrentOperationCount = 4
26
+ return URLSession(configuration: config, delegate: self, delegateQueue: opQueue)
27
+ }()
28
+
29
+ /// State for a single in-progress download.
30
+ private class ActiveDownload {
31
+ let url: URL
32
+ let cacheKey: String
33
+ var task: URLSessionDataTask?
34
+ var contentInfo: AudioCache.ContentInfo?
35
+ var maxBytes: Int64?
36
+ var subscribers: [(token: DownloadToken, completion: (Result<Void, Error>) -> Void)] = []
37
+
38
+ init(url: URL, cacheKey: String) {
39
+ self.url = url
40
+ self.cacheKey = cacheKey
41
+ }
42
+ }
43
+
44
+ /// Map of cache key → active download.
45
+ private var downloads: [String: ActiveDownload] = [:]
46
+ /// Map of URLSessionTask identifier → cache key (for delegate routing).
47
+ private var taskKeyMap: [Int: String] = [:]
48
+
49
+ /// Called when any download completes successfully.
50
+ /// Parameters: cache key, whether the full file was downloaded (false when stopped by maxBytes).
51
+ var onDownloadComplete: ((_ key: String, _ isFull: Bool) -> Void)?
52
+
53
+ var activeDownloadCount: Int {
54
+ sessionQueue.sync { downloads.count }
55
+ }
56
+
57
+ init(cache: AudioCache) {
58
+ self.cache = cache
59
+ }
60
+
61
+ // MARK: - Public
62
+
63
+ /// Start or attach to a download for the given URL.
64
+ /// Returns a token that can be used to cancel this subscriber.
65
+ /// - Parameter cacheKey: Explicit cache key. If nil, computed from the URL.
66
+ /// Pass this when the URL has gone through encoding/decoding that might
67
+ /// change its string representation (e.g. proxy query parameters).
68
+ @discardableResult
69
+ func download(
70
+ url: URL,
71
+ headers: [String: String]?,
72
+ cacheKey: String? = nil,
73
+ maxBytes: Int64? = nil,
74
+ completion: @escaping (Result<Void, Error>) -> Void = { _ in }
75
+ ) -> DownloadToken {
76
+ let key = cacheKey ?? cache.cacheKey(for: url)
77
+ let token = DownloadToken(cacheKey: key)
78
+
79
+ sessionQueue.async { [self] in
80
+ if cache.isFullyCached(key: key) {
81
+ DispatchQueue.global().async { completion(.success(())) }
82
+ return
83
+ }
84
+
85
+ if let existing = downloads[key] {
86
+ existing.subscribers.append((token, completion))
87
+ return
88
+ }
89
+
90
+ let dl = ActiveDownload(url: url, cacheKey: key)
91
+ dl.maxBytes = maxBytes
92
+ dl.subscribers.append((token, completion))
93
+
94
+ var request = URLRequest(url: url)
95
+ headers?.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
96
+
97
+ let cachedBytes = cache.cachedBytes(for: key)
98
+ if cachedBytes > 0 {
99
+ request.setValue("bytes=\(cachedBytes)-", forHTTPHeaderField: "Range")
100
+ }
101
+
102
+ let task = session.dataTask(with: request)
103
+ dl.task = task
104
+ downloads[key] = dl
105
+ taskKeyMap[task.taskIdentifier] = key
106
+
107
+ task.resume()
108
+ }
109
+
110
+ return token
111
+ }
112
+
113
+ /// Cancel a specific subscriber. If no subscribers remain, the download is cancelled.
114
+ func cancelDownload(token: DownloadToken) {
115
+ sessionQueue.async { [self] in
116
+ guard let dl = downloads[token.cacheKey] else { return }
117
+ dl.subscribers.removeAll { $0.token == token }
118
+ if dl.subscribers.isEmpty {
119
+ dl.task?.cancel()
120
+ if let taskId = dl.task?.taskIdentifier {
121
+ taskKeyMap.removeValue(forKey: taskId)
122
+ }
123
+ downloads.removeValue(forKey: token.cacheKey)
124
+ }
125
+ }
126
+ }
127
+
128
+ /// Cancel all active downloads.
129
+ func cancelAll() {
130
+ sessionQueue.async { [self] in
131
+ for (_, dl) in downloads {
132
+ dl.task?.cancel()
133
+ }
134
+ downloads.removeAll()
135
+ taskKeyMap.removeAll()
136
+ }
137
+ }
138
+
139
+ // MARK: - Private
140
+
141
+ private func activeDownload(for task: URLSessionTask) -> ActiveDownload? {
142
+ guard let key = taskKeyMap[task.taskIdentifier] else { return nil }
143
+ return downloads[key]
144
+ }
145
+
146
+ private func notifySubscribers(for key: String, result: Result<Void, Error>, isFull: Bool = true) {
147
+ guard let dl = downloads.removeValue(forKey: key) else { return }
148
+ if let taskId = dl.task?.taskIdentifier {
149
+ taskKeyMap.removeValue(forKey: taskId)
150
+ }
151
+ for sub in dl.subscribers {
152
+ DispatchQueue.global().async { sub.completion(result) }
153
+ }
154
+ if case .success = result {
155
+ onDownloadComplete?(key, isFull)
156
+ }
157
+ }
158
+ }
159
+
160
+ // MARK: - URLSessionDataDelegate
161
+
162
+ extension DownloadCoordinator: URLSessionDataDelegate {
163
+
164
+ func urlSession(
165
+ _ session: URLSession,
166
+ dataTask: URLSessionDataTask,
167
+ didReceive response: URLResponse,
168
+ completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
169
+ ) {
170
+ guard let dl = activeDownload(for: dataTask),
171
+ let http = response as? HTTPURLResponse else {
172
+ completionHandler(.allow)
173
+ return
174
+ }
175
+
176
+ if cache.contentInfo(for: dl.cacheKey) == nil {
177
+ let mime = http.mimeType ?? "audio/mpeg"
178
+ var totalLength: Int64 = -1
179
+
180
+ if http.statusCode == 206,
181
+ let rangeHeader = http.value(forHTTPHeaderField: "Content-Range"),
182
+ let slashIdx = rangeHeader.lastIndex(of: "/") {
183
+ totalLength = Int64(rangeHeader[rangeHeader.index(after: slashIdx)...]) ?? -1
184
+ } else {
185
+ totalLength = response.expectedContentLength
186
+ }
187
+
188
+ let byteRange = http.statusCode == 206
189
+ let info = AudioCache.ContentInfo(
190
+ contentType: mime,
191
+ contentLength: totalLength,
192
+ isByteRangeAccessSupported: byteRange
193
+ )
194
+ cache.storeContentInfo(info, for: dl.cacheKey, url: dl.url)
195
+ downloads[dl.cacheKey]?.contentInfo = info
196
+ }
197
+
198
+ completionHandler(.allow)
199
+ }
200
+
201
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
202
+ guard let dl = activeDownload(for: dataTask) else { return }
203
+ cache.appendData(data, for: dl.cacheKey)
204
+
205
+ // Stop download if byte limit reached.
206
+ if let maxBytes = dl.maxBytes, cache.cachedBytes(for: dl.cacheKey) >= maxBytes {
207
+ cache.touchAccessDate(for: dl.cacheKey)
208
+ cache.evictIfNeeded()
209
+ dataTask.cancel()
210
+ notifySubscribers(for: dl.cacheKey, result: .success(()), isFull: false)
211
+ }
212
+ }
213
+
214
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
215
+ guard let key = taskKeyMap[task.taskIdentifier] else { return }
216
+
217
+ if let error = error as? URLError, error.code == .cancelled { return }
218
+
219
+ if let error = error {
220
+ notifySubscribers(for: key, result: .failure(error))
221
+ return
222
+ }
223
+
224
+ // Finalize content length if the server didn't provide it.
225
+ if let info = cache.contentInfo(for: key), info.contentLength <= 0 {
226
+ let actualLength = cache.cachedBytes(for: key)
227
+ if actualLength > 0 {
228
+ let updated = AudioCache.ContentInfo(
229
+ contentType: info.contentType,
230
+ contentLength: actualLength,
231
+ isByteRangeAccessSupported: info.isByteRangeAccessSupported
232
+ )
233
+ cache.storeContentInfo(updated, for: key, url: downloads[key]?.url ?? URL(fileURLWithPath: "/"))
234
+ }
235
+ }
236
+
237
+ cache.touchAccessDate(for: key)
238
+ cache.evictIfNeeded()
239
+
240
+ notifySubscribers(for: key, result: .success(()))
241
+ }
242
+ }