react-native-hls-cache 0.1.0

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 (52) hide show
  1. package/HlsCache.podspec +29 -0
  2. package/LICENSE +20 -0
  3. package/README.md +233 -0
  4. package/android/CMakeLists.txt +24 -0
  5. package/android/build.gradle +118 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +6 -0
  8. package/android/src/main/java/com/margelo/nitro/hlscache/HlsCache.kt +32 -0
  9. package/android/src/main/java/com/margelo/nitro/hlscache/HlsCachePackage.kt +22 -0
  10. package/ios/ClientConnectionHandler.swift +234 -0
  11. package/ios/DataSource.swift +394 -0
  12. package/ios/HlsCache.swift +121 -0
  13. package/ios/NetworkDownloader.swift +220 -0
  14. package/ios/PrefetchManager.swift +235 -0
  15. package/ios/VideoCacheStorage.swift +235 -0
  16. package/ios/VideoProxyServer.swift +185 -0
  17. package/lib/module/HlsCache.nitro.js +4 -0
  18. package/lib/module/HlsCache.nitro.js.map +1 -0
  19. package/lib/module/index.js +71 -0
  20. package/lib/module/index.js.map +1 -0
  21. package/lib/module/package.json +1 -0
  22. package/lib/typescript/package.json +1 -0
  23. package/lib/typescript/src/HlsCache.nitro.d.ts +13 -0
  24. package/lib/typescript/src/HlsCache.nitro.d.ts.map +1 -0
  25. package/lib/typescript/src/index.d.ts +36 -0
  26. package/lib/typescript/src/index.d.ts.map +1 -0
  27. package/nitro.json +23 -0
  28. package/nitrogen/generated/android/c++/JHybridHlsCacheSpec.cpp +89 -0
  29. package/nitrogen/generated/android/c++/JHybridHlsCacheSpec.hpp +68 -0
  30. package/nitrogen/generated/android/hlscache+autolinking.cmake +81 -0
  31. package/nitrogen/generated/android/hlscache+autolinking.gradle +27 -0
  32. package/nitrogen/generated/android/hlscacheOnLoad.cpp +54 -0
  33. package/nitrogen/generated/android/hlscacheOnLoad.hpp +34 -0
  34. package/nitrogen/generated/android/kotlin/com/margelo/nitro/hlscache/HybridHlsCacheSpec.kt +75 -0
  35. package/nitrogen/generated/android/kotlin/com/margelo/nitro/hlscache/hlscacheOnLoad.kt +35 -0
  36. package/nitrogen/generated/ios/HlsCache+autolinking.rb +60 -0
  37. package/nitrogen/generated/ios/HlsCache-Swift-Cxx-Bridge.cpp +49 -0
  38. package/nitrogen/generated/ios/HlsCache-Swift-Cxx-Bridge.hpp +160 -0
  39. package/nitrogen/generated/ios/HlsCache-Swift-Cxx-Umbrella.hpp +46 -0
  40. package/nitrogen/generated/ios/HlsCacheAutolinking.mm +33 -0
  41. package/nitrogen/generated/ios/HlsCacheAutolinking.swift +26 -0
  42. package/nitrogen/generated/ios/c++/HybridHlsCacheSpecSwift.cpp +11 -0
  43. package/nitrogen/generated/ios/c++/HybridHlsCacheSpecSwift.hpp +118 -0
  44. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  45. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  46. package/nitrogen/generated/ios/swift/HybridHlsCacheSpec.swift +60 -0
  47. package/nitrogen/generated/ios/swift/HybridHlsCacheSpec_cxx.swift +237 -0
  48. package/nitrogen/generated/shared/c++/HybridHlsCacheSpec.cpp +26 -0
  49. package/nitrogen/generated/shared/c++/HybridHlsCacheSpec.hpp +69 -0
  50. package/package.json +174 -0
  51. package/src/HlsCache.nitro.ts +15 -0
  52. package/src/index.tsx +75 -0
@@ -0,0 +1,234 @@
1
+ import Foundation
2
+ import Network
3
+
4
+ /// Manages a single active TCP connection from the media player.
5
+ ///
6
+ /// This class acts as the intermediary between the client (AVPlayer) and the data source.
7
+ /// It is responsible for parsing the raw HTTP request headers, extracting the requested
8
+ /// byte range and target URL, and streaming the resulting data back to the client socket.
9
+ internal final class ClientConnectionHandler: DataSourceDelegate {
10
+
11
+ // MARK: - Properties
12
+
13
+ /// Unique identifier for tracing the lifecycle of this connection.
14
+ let id = UUID().uuidString.prefix(4).description
15
+
16
+ /// Delegate to notify the parent server when this connection closes.
17
+ weak var delegate: ProxyConnectionDelegate?
18
+
19
+ /// The TCP connection provided by Network framework.
20
+ private let connection: NWConnection
21
+
22
+ /// Shared video cache storage.
23
+ private let storage: VideoCacheStorage
24
+
25
+ /// The local proxy port used for manifest rewriting.
26
+ private let port: Int
27
+
28
+ /// The current data source responsible for fetching or streaming the requested content.
29
+ private var dataSource: DataSource?
30
+
31
+ /// Buffer to accumulate incoming raw HTTP request bytes.
32
+ private var buffer = Data()
33
+
34
+ /// Guards against concurrent or duplicate calls to `stop()`.
35
+ private let stopLock = NSLock()
36
+ private var isStopped = false
37
+
38
+ /// Tracks whether HTTP response headers have been sent to the client.
39
+ /// Used to decide whether a 502 error can still be sent on network failure.
40
+ private var headersSent = false
41
+
42
+ /// Initial segments to cache.
43
+ /// If 0, all segments are cached.
44
+ /// If positive, only the first N segments are cached.
45
+ private let segmentLimit: Int
46
+
47
+ // MARK: - Initialization
48
+
49
+ /// Initializes a new connection handler.
50
+ ///
51
+ /// - Parameters:
52
+ /// - connection: The accepted TCP connection.
53
+ /// - storage: The shared cache storage manager.
54
+ /// - port: The port number of the proxy server.
55
+ /// - initialSegmentsToCache: The number of initial segments to cache.
56
+ init(connection: NWConnection, storage: VideoCacheStorage, port: Int, initialSegmentsToCache: Int) {
57
+ self.connection = connection
58
+ self.storage = storage
59
+ self.port = port
60
+ self.segmentLimit = initialSegmentsToCache
61
+ }
62
+
63
+ // MARK: - Lifecycle Methods
64
+
65
+ /// Opens the socket and begins listening for incoming data.
66
+ ///
67
+ /// Starts a background read loop and monitors connection state.
68
+ func start() {
69
+ connection.stateUpdateHandler = { [weak self] state in
70
+ switch state {
71
+ case .failed, .cancelled:
72
+ self?.stop()
73
+ default:
74
+ break
75
+ }
76
+ }
77
+
78
+ connection.start(queue: .global(qos: .userInteractive))
79
+ readHeader()
80
+ }
81
+
82
+ /// Terminates the connection and releases associated resources.
83
+ ///
84
+ /// Cancels any active data source and closes the socket if still open.
85
+ /// This method is idempotent: subsequent calls after the first are no-ops.
86
+ func stop() {
87
+ stopLock.lock()
88
+ guard !isStopped else {
89
+ stopLock.unlock()
90
+ return
91
+ }
92
+ isStopped = true
93
+ stopLock.unlock()
94
+
95
+ dataSource?.cancel()
96
+ dataSource = nil
97
+
98
+ if connection.state != .cancelled {
99
+ connection.cancel()
100
+ }
101
+
102
+ delegate?.connectionDidClose(id: id)
103
+ }
104
+
105
+ // MARK: - HTTP Parsing
106
+
107
+ /// Reads incoming TCP bytes until a full HTTP header (`\r\n\r\n`) is received.
108
+ ///
109
+ /// Accumulates bytes in `buffer` and calls `parseRequest(_:)` once complete.
110
+ private func readHeader() {
111
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
112
+ guard let self = self else { return }
113
+
114
+ if let data = data, !data.isEmpty {
115
+ self.buffer.append(data)
116
+
117
+ if let range = self.buffer.range(of: Data([0x0D, 0x0A, 0x0D, 0x0A])) {
118
+ let headerData = self.buffer.subdata(in: 0..<range.lowerBound)
119
+ self.parseRequest(headerData)
120
+ self.buffer.removeAll(keepingCapacity: false)
121
+ } else {
122
+ self.readHeader()
123
+ }
124
+ } else if isComplete || error != nil {
125
+ self.stop()
126
+ }
127
+ }
128
+ }
129
+
130
+ /// Parses the raw HTTP header to extract the target URL and optional byte range.
131
+ ///
132
+ /// - Parameter data: Raw HTTP header bytes.
133
+ /// - Returns: None. Initializes a `DataSource` and starts streaming.
134
+ private func parseRequest(_ data: Data) {
135
+ guard let string = String(data: data, encoding: .utf8) else { return stop() }
136
+ let lines = string.components(separatedBy: "\r\n")
137
+ guard let requestLine = lines.first else { return stop() }
138
+
139
+ let parts = requestLine.components(separatedBy: " ")
140
+ if parts.count < 2 { return stop() }
141
+
142
+ let path = parts[1]
143
+ guard let range = path.range(of: "url=") else { return stop() }
144
+
145
+ let urlPart = String(path[range.upperBound...]).components(separatedBy: "&")[0]
146
+ guard let decoded = urlPart.removingPercentEncoding, let url = URL(string: decoded) else { return stop() }
147
+
148
+ var byteRange: Range<Int>? = nil
149
+ for line in lines {
150
+ if line.lowercased().hasPrefix("range: bytes=") {
151
+ let val = line.dropFirst(13)
152
+ let components = val.components(separatedBy: "-")
153
+ if let start = Int(components[0]) {
154
+ let end = (components.count > 1 && !components[1].isEmpty) ? Int(components[1]) : nil
155
+ let safeEnd = (end != nil) ? (end! + 1) : Int.max
156
+ byteRange = start..<safeEnd
157
+ }
158
+ break
159
+ }
160
+ }
161
+
162
+ dataSource = DataSource(
163
+ storage: storage,
164
+ url: url,
165
+ range: byteRange,
166
+ port: port,
167
+ segmentLimit: segmentLimit
168
+ )
169
+ dataSource?.delegate = self
170
+ dataSource?.start()
171
+ }
172
+
173
+ // MARK: - DataSourceDelegate Methods
174
+
175
+ /// Called when the data source is ready to send HTTP headers.
176
+ ///
177
+ /// - Parameters:
178
+ /// - headers: HTTP headers (e.g., Content-Type, Content-Length).
179
+ /// - status: HTTP status code (200 or 206).
180
+ func didReceiveHeaders(headers: [String : String], status: Int) {
181
+ headersSent = true
182
+
183
+ let statusText: String
184
+ switch status {
185
+ case 200: statusText = "OK"
186
+ case 206: statusText = "Partial Content"
187
+ default: statusText = "OK"
188
+ }
189
+
190
+ var response = "HTTP/1.1 \(status) \(statusText)\r\n"
191
+ response += "Connection: close\r\n"
192
+ response += "Access-Control-Allow-Origin: *\r\n"
193
+
194
+ for (k, v) in headers {
195
+ response += "\(k): \(v)\r\n"
196
+ }
197
+ response += "\r\n"
198
+
199
+ connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in })
200
+ }
201
+
202
+ /// Called when a chunk of data is available from the data source.
203
+ ///
204
+ /// - Parameter data: Raw data to be streamed to the client.
205
+ func didReceiveData(data: Data) {
206
+ connection.send(content: data, completion: .contentProcessed { _ in })
207
+ }
208
+
209
+ /// Called when the data source has finished or failed.
210
+ ///
211
+ /// - Parameter error: Optional error if streaming failed.
212
+ func didComplete(error: Error?) {
213
+ if error != nil {
214
+ if !headersSent {
215
+ // No headers have been sent yet — the connection can still receive a clean
216
+ // HTTP error. A TCP drop (the previous behaviour) causes AVPlayer to treat
217
+ // the failure as a connect error rather than an HTTP error, which prevents
218
+ // it from retrying or showing a useful error state.
219
+ let response = "HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
220
+ connection.send(content: response.data(using: .utf8), completion: .contentProcessed { [weak self] _ in
221
+ self?.stop()
222
+ })
223
+ } else {
224
+ // Headers were already sent — we're mid-stream. Just close; the player
225
+ // will detect the truncated response and handle it.
226
+ stop()
227
+ }
228
+ } else {
229
+ connection.send(content: nil, contentContext: .defaultStream, isComplete: true, completion: .contentProcessed { [weak self] _ in
230
+ self?.stop()
231
+ })
232
+ }
233
+ }
234
+ }
@@ -0,0 +1,394 @@
1
+ import Foundation
2
+ import Network
3
+
4
+ /// A delegate protocol for receiving data transfer events from the DataSource.
5
+ protocol DataSourceDelegate: AnyObject {
6
+
7
+ /// Called when the source is ready to transmit headers.
8
+ /// - Parameters:
9
+ /// - headers: A dictionary of HTTP headers (e.g., `Content-Length`, `Content-Type`).
10
+ /// - status: The HTTP status code (e.g., 200, 206).
11
+ func didReceiveHeaders(headers: [String: String], status: Int)
12
+
13
+ /// Called when a chunk of data is available for streaming.
14
+ /// - Parameter data: The raw data chunk.
15
+ func didReceiveData(data: Data)
16
+
17
+ /// Called when the data transfer is complete or has failed.
18
+ /// - Parameter error: An optional error if the transfer failed.
19
+ func didComplete(error: Error?)
20
+ }
21
+
22
+ /// The logic unit responsible for fulfilling a specific data request.
23
+ ///
24
+ /// This class acts as a router that determines whether to serve data from the local disk cache
25
+ /// or fetch it from the network via `NetworkDownloader`. It also handles the rewriting of HLS
26
+ /// manifests to ensure subsequent segment requests are routed through the local proxy.
27
+ internal final class DataSource: NetworkDownloaderDelegate {
28
+
29
+ // MARK: - Properties
30
+
31
+ private let storage: VideoCacheStorage
32
+ private let url: URL
33
+ private let range: Range<Int>?
34
+ private let port: Int
35
+
36
+ weak var delegate: DataSourceDelegate?
37
+
38
+ private let dataLock = NSLock()
39
+ private var networkTask: NetworkTask?
40
+ private var fileHandle: FileHandle?
41
+ private var isManifest: Bool
42
+ private let segmentLimit: Int
43
+
44
+ private static let diskQueue = DispatchQueue(label: "com.hlscache.disk", qos: .userInitiated)
45
+
46
+ /// Character set used when percent-encoding a URL to embed as a proxy query-parameter value.
47
+ ///
48
+ /// `.urlQueryAllowed` keeps `&` and `#` unencoded, which breaks the proxy's own query-string
49
+ /// parser when the embedded URL contains those characters (e.g. `?token=a&sig=b` would be
50
+ /// silently truncated to `?token=a`). Removing them forces proper percent-encoding.
51
+ private static let proxyQueryAllowed: CharacterSet = {
52
+ var set = CharacterSet.urlQueryAllowed
53
+ set.remove(charactersIn: "&#")
54
+ return set
55
+ }()
56
+
57
+ /// Generates a unique key for the file on disk.
58
+ ///
59
+ /// This key appends the byte range to the URL to prevent file collisions in fMP4 streams,
60
+ /// where the same URL is often used for both initialization and media segments.
61
+ private var storageKey: String {
62
+ if let r = range {
63
+ return "\(url.absoluteString)-\(r.lowerBound)-\(r.upperBound)"
64
+ }
65
+ return url.absoluteString
66
+ }
67
+
68
+ // MARK: - Initialization
69
+
70
+ /// Initializes a new data source request.
71
+ ///
72
+ /// - Parameters:
73
+ /// - storage: The cache storage manager.
74
+ /// - url: The target URL.
75
+ /// - range: The requested byte range (optional).
76
+ /// - port: The local proxy port (used for manifest rewriting).
77
+ /// - segmentLimit: The number of initial segments to cache.
78
+ init(storage: VideoCacheStorage, url: URL, range: Range<Int>?, port: Int, segmentLimit: Int) {
79
+ self.storage = storage
80
+ self.url = url
81
+ self.range = range
82
+ self.port = port
83
+ self.segmentLimit = segmentLimit
84
+
85
+ let urlString = url.absoluteString.lowercased()
86
+ self.isManifest = url.pathExtension.lowercased().contains("m3u8") || urlString.contains(".m3u8")
87
+ }
88
+
89
+ // MARK: - Lifecycle
90
+
91
+ /// Begins the data retrieval process.
92
+ ///
93
+ /// Checks the disk cache first; if the data is missing, initiates a network download.
94
+ func start() {
95
+ if storage.exists(for: storageKey) {
96
+ if isManifest {
97
+ serveManifestFromCache()
98
+ } else {
99
+ DataSource.diskQueue.async { self.serveFileFromDisk() }
100
+ }
101
+ return
102
+ }
103
+
104
+ // Range-keyed file not found — fall back to the full file that prefetch may have
105
+ // stored without a range suffix. Slice the requested bytes out of it on the fly.
106
+ if range != nil && !isManifest && storage.exists(for: url.absoluteString) {
107
+ DataSource.diskQueue.async { self.serveRangeFromFullFile() }
108
+ return
109
+ }
110
+
111
+ if isManifest {
112
+ downloadManifest()
113
+ } else {
114
+ startStreamDownload()
115
+ }
116
+ }
117
+
118
+ /// Cancels any active network tasks or file I/O.
119
+ func cancel() {
120
+ dataLock.lock()
121
+ let task = networkTask
122
+ networkTask = nil
123
+ dataLock.unlock()
124
+
125
+ task?.cancel()
126
+ closeFileHandle()
127
+ }
128
+
129
+ private func closeFileHandle() {
130
+ dataLock.lock()
131
+ let handle = fileHandle
132
+ fileHandle = nil
133
+ dataLock.unlock()
134
+
135
+ try? handle?.close()
136
+ }
137
+
138
+ // MARK: - Disk Path (Cache Hit)
139
+
140
+ private func serveFileFromDisk() {
141
+ let path = storage.getFilePath(for: storageKey)
142
+
143
+ // Memory-map the file: zero-copy, OS handles paging — no read loop needed.
144
+ guard let data = try? Data(contentsOf: path, options: .mappedIfSafe) else {
145
+ delegate?.didComplete(error: NSError(domain: "DiskError", code: 500))
146
+ return
147
+ }
148
+
149
+ let fileSize = data.count
150
+
151
+ if fileSize == 0 {
152
+ let headers = ["Content-Length": "0", "Content-Type": getMimeType(url: url)]
153
+ delegate?.didReceiveHeaders(headers: headers, status: 200)
154
+ delegate?.didComplete(error: nil)
155
+ return
156
+ }
157
+
158
+ var status = 200
159
+ var headers: [String: String] = [
160
+ "Content-Type": getMimeType(url: url),
161
+ "Content-Length": "\(fileSize)",
162
+ "Accept-Ranges": "bytes"
163
+ ]
164
+
165
+ // When the original request included a Range header, the cached file contains
166
+ // exactly those bytes. Return 206 + Content-Range so AVPlayer treats it the
167
+ // same as an online range response — returning 200 here causes AVPlayer to
168
+ // misinterpret the data boundaries and fail to render the segment offline.
169
+ if let r = range {
170
+ status = 206
171
+ let endByte = r.upperBound == Int.max
172
+ ? (r.lowerBound + fileSize - 1)
173
+ : (r.upperBound - 1)
174
+ headers["Content-Range"] = "bytes \(r.lowerBound)-\(endByte)/*"
175
+ }
176
+
177
+ delegate?.didReceiveHeaders(headers: headers, status: status)
178
+ delegate?.didReceiveData(data: data)
179
+ delegate?.didComplete(error: nil)
180
+ }
181
+
182
+ /// Serves a byte-range slice from a full file that was cached by prefetch (no range suffix).
183
+ private func serveRangeFromFullFile() {
184
+ let path = storage.getFilePath(for: url.absoluteString)
185
+ guard let fullData = try? Data(contentsOf: path, options: .mappedIfSafe),
186
+ let r = range else {
187
+ // Full file unreadable — fall through to network.
188
+ startStreamDownload()
189
+ return
190
+ }
191
+
192
+ let fileSize = fullData.count
193
+ let lower = r.lowerBound
194
+ let upper = r.upperBound == Int.max ? fileSize : min(r.upperBound, fileSize)
195
+
196
+ guard lower < fileSize else {
197
+ delegate?.didComplete(error: NSError(domain: "RangeError", code: 416))
198
+ return
199
+ }
200
+
201
+ let sliced = Data(fullData[lower..<upper])
202
+ let headers: [String: String] = [
203
+ "Content-Type": getMimeType(url: url),
204
+ "Content-Length": "\(sliced.count)",
205
+ "Accept-Ranges": "bytes",
206
+ "Content-Range": "bytes \(lower)-\(upper - 1)/\(fileSize)"
207
+ ]
208
+ delegate?.didReceiveHeaders(headers: headers, status: 206)
209
+ delegate?.didReceiveData(data: sliced)
210
+ delegate?.didComplete(error: nil)
211
+ }
212
+
213
+ // MARK: - Network Path (Cache Miss)
214
+
215
+ private func startStreamDownload() {
216
+ let task = NetworkDownloader.shared.download(url: url, range: range, delegate: self)
217
+ dataLock.lock()
218
+ networkTask = task
219
+ dataLock.unlock()
220
+ }
221
+
222
+ // MARK: - NetworkDownloaderDelegate
223
+
224
+ func didReceiveResponse(task: NetworkTask, response: URLResponse) {
225
+ if let httpResponse = response as? HTTPURLResponse {
226
+ if (200...299).contains(httpResponse.statusCode) {
227
+ let handle = storage.initializeStreamFile(for: storageKey)
228
+ dataLock.lock()
229
+ fileHandle = handle
230
+ dataLock.unlock()
231
+ }
232
+
233
+ var headers = [String:String]()
234
+ for (k, v) in httpResponse.allHeaderFields {
235
+ if let ks = k as? String, let vs = v as? String { headers[ks] = vs }
236
+ }
237
+
238
+ headers["Content-Type"] = getMimeType(url: url)
239
+ delegate?.didReceiveHeaders(headers: headers, status: httpResponse.statusCode)
240
+ }
241
+ }
242
+
243
+ func didReceiveData(task: NetworkTask, data: Data) {
244
+ delegate?.didReceiveData(data: data)
245
+ dataLock.lock()
246
+ let handle = fileHandle
247
+ dataLock.unlock()
248
+ try? handle?.write(contentsOf: data)
249
+ }
250
+
251
+ func didComplete(task: NetworkTask, error: Error?) {
252
+ closeFileHandle()
253
+
254
+ if error != nil {
255
+ if storage.exists(for: storageKey) {
256
+ storage.delete(for: storageKey)
257
+ }
258
+ }
259
+
260
+ delegate?.didComplete(error: error)
261
+ }
262
+
263
+ // MARK: - Manifest Handling
264
+
265
+ private func serveManifestFromCache() {
266
+ guard let data = storage.getCachedData(for: storageKey),
267
+ let content = String(data: data, encoding: .utf8) else {
268
+ delegate?.didComplete(error: NSError(domain: "CacheError", code: 404))
269
+ return
270
+ }
271
+ sendRewrittenManifest(content)
272
+ }
273
+
274
+ private func downloadManifest() {
275
+ // Use the shared managed session (same priority queue as segments, not URLSession.shared).
276
+ let task = NetworkDownloader.shared.downloadManifest(url: url) { [weak self] data, error in
277
+ guard let self = self else { return }
278
+ guard let data = data, let content = String(data: data, encoding: .utf8) else {
279
+ self.delegate?.didComplete(error: error ?? NSError(domain: "NetError", code: 500))
280
+ return
281
+ }
282
+ self.storage.save(data: data, for: self.storageKey)
283
+ self.sendRewrittenManifest(content)
284
+ }
285
+ dataLock.lock()
286
+ networkTask = task
287
+ dataLock.unlock()
288
+ }
289
+
290
+ private func sendRewrittenManifest(_ content: String) {
291
+ let rewritten = rewriteManifest(content, originalUrl: url)
292
+
293
+ // Encode the rewritten manifest once so we can set an accurate Content-Length.
294
+ // Without Content-Length AVPlayer must wait for the connection to close to know
295
+ // the manifest is complete, which adds latency and can cause playback stalls.
296
+ guard let data = rewritten.data(using: .utf8) else {
297
+ delegate?.didComplete(error: NSError(domain: "ManifestError", code: 500))
298
+ return
299
+ }
300
+
301
+ let headers: [String: String] = [
302
+ "Content-Type": "application/vnd.apple.mpegurl",
303
+ "Content-Length": "\(data.count)"
304
+ ]
305
+ delegate?.didReceiveHeaders(headers: headers, status: 200)
306
+ delegate?.didReceiveData(data: data)
307
+ delegate?.didComplete(error: nil)
308
+ }
309
+
310
+ /// Rewrites the HLS manifest content to route segment requests through the local proxy.
311
+ /// - Parameters:
312
+ /// - content: The raw manifest string.
313
+ /// - originalUrl: The original URL of the manifest, used for resolving relative paths.
314
+ /// - Returns: A modified manifest string with localhost URLs.
315
+ private func rewriteManifest(_ content: String, originalUrl: URL) -> String {
316
+ let lines = content.components(separatedBy: .newlines)
317
+ var rewritten: [String] = []
318
+ var segmentCount = 0
319
+
320
+ let isMasterPlaylist = content.contains("#EXT-X-STREAM-INF")
321
+
322
+ for line in lines {
323
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
324
+ if trimmed.isEmpty {
325
+ rewritten.append(line)
326
+ continue
327
+ }
328
+
329
+ if trimmed.hasPrefix("#") {
330
+ if trimmed.contains("URI=\"") {
331
+ rewritten.append(rewriteHlsTag(line: line, originalUrl: originalUrl))
332
+ } else {
333
+ rewritten.append(line)
334
+ }
335
+ continue
336
+ }
337
+
338
+ if isMasterPlaylist {
339
+ rewritten.append(rewriteLineToProxy(line: line, originalUrl: originalUrl))
340
+ } else {
341
+ if segmentLimit == 0 || segmentCount < segmentLimit {
342
+ rewritten.append(rewriteLineToProxy(line: line, originalUrl: originalUrl))
343
+ } else {
344
+ rewritten.append(rewriteLineToDirect(line: line, originalUrl: originalUrl))
345
+ }
346
+ segmentCount += 1
347
+ }
348
+ }
349
+ return rewritten.joined(separator: "\n")
350
+ }
351
+
352
+ private func rewriteLineToProxy(line: String, originalUrl: URL) -> String {
353
+ if line.hasPrefix("#") { return line }
354
+
355
+ let absolute = URL(string: line, relativeTo: originalUrl)?.absoluteString ?? line
356
+ guard let encoded = absolute.addingPercentEncoding(withAllowedCharacters: DataSource.proxyQueryAllowed) else { return line }
357
+
358
+ return "http://127.0.0.1:\(port)/proxy?url=\(encoded)"
359
+ }
360
+
361
+ private func rewriteLineToDirect(line: String, originalUrl: URL) -> String {
362
+ if line.hasPrefix("#") { return line }
363
+ return URL(string: line, relativeTo: originalUrl)?.absoluteString ?? line
364
+ }
365
+
366
+ private func rewriteHlsTag(line: String, originalUrl: URL) -> String {
367
+ let components = line.components(separatedBy: "URI=\"")
368
+ if components.count < 2 { return line }
369
+
370
+ let prefix = components[0]
371
+ let rest = components[1]
372
+
373
+ if let quoteIndex = rest.firstIndex(of: "\"") {
374
+ let uriPart = String(rest[..<quoteIndex])
375
+ let suffix = String(rest[rest.index(after: quoteIndex)...])
376
+
377
+ let newUri = rewriteLineToProxy(line: uriPart, originalUrl: originalUrl)
378
+ return "\(prefix)URI=\"\(newUri)\"\(suffix)"
379
+ }
380
+ return line
381
+ }
382
+
383
+ private func getMimeType(url: URL) -> String {
384
+ let ext = url.pathExtension.lowercased()
385
+ switch ext {
386
+ case "m3u8": return "application/vnd.apple.mpegurl"
387
+ case "mp4": return "video/mp4"
388
+ case "m4s": return "video/iso.segment"
389
+ case "m4a": return "audio/mp4"
390
+ case "ts": return "video/mp2t"
391
+ default: return "application/octet-stream"
392
+ }
393
+ }
394
+ }