@rntp/player 5.0.0-beta.3 → 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 +107 -87
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/BrowseTree.kt +51 -20
- 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/CarPlay/RNTPCarPlaySceneDelegate.swift +43 -14
- 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 +39 -4
- 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 +37 -4
- 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 +16 -4
- package/lib/typescript/src/audio.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/BrowseTree.d.ts +35 -5
- package/lib/typescript/src/interfaces/BrowseTree.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 +37 -4
- package/src/interfaces/BrowseTree.ts +40 -5
- package/src/interfaces/MediaItem.ts +4 -1
- package/src/interfaces/PlayerConfig.ts +22 -3
- 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
|
+
}
|