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,121 @@
1
+ import Foundation
2
+ import NitroModules
3
+
4
+ public class HybridHlsCache: HybridHlsCacheSpec {
5
+
6
+ // MARK: - State
7
+
8
+ private let stateLock = NSLock()
9
+ private var proxyServer: VideoProxyServer?
10
+ private var activePort: Int = 9000
11
+
12
+ // MARK: - HybridHlsCacheSpec
13
+
14
+ /// Light sync method — NWListener setup is < 1ms, no Promise needed.
15
+ public func startServer(port: Double?, maxCacheSize: Double?, headOnlyCache: Bool?) throws -> Void {
16
+ let cacheLimit = maxCacheSize.map { Int($0) } ?? 1_073_741_824
17
+ let targetPort = port.map { Int($0) } ?? 9000
18
+
19
+ stateLock.lock()
20
+ let currentServer = proxyServer
21
+ let currentPort = activePort
22
+ stateLock.unlock()
23
+
24
+ if let server = currentServer, server.isRunning {
25
+ if currentPort == targetPort { return }
26
+ throw NSError(
27
+ domain: "HlsCache",
28
+ code: 409,
29
+ userInfo: [NSLocalizedDescriptionKey: "Server active on \(currentPort). Reload required."]
30
+ )
31
+ }
32
+
33
+ let newServer = VideoProxyServer(
34
+ port: targetPort,
35
+ maxCacheSize: cacheLimit,
36
+ headOnlyCache: headOnlyCache ?? false
37
+ )
38
+
39
+ do {
40
+ try newServer.start()
41
+ } catch {
42
+ throw NSError(
43
+ domain: "HlsCache",
44
+ code: 500,
45
+ userInfo: [NSLocalizedDescriptionKey: "Port bind failed: \(error.localizedDescription)"]
46
+ )
47
+ }
48
+
49
+ stateLock.lock()
50
+ proxyServer = newServer
51
+ activePort = targetPort
52
+ stateLock.unlock()
53
+ }
54
+
55
+ /// Light sync method — just builds a URL string.
56
+ public func convertUrl(url: String, isCacheable: Bool?) throws -> String {
57
+ if isCacheable == false { return url }
58
+
59
+ stateLock.lock()
60
+ let server = proxyServer
61
+ let port = activePort
62
+ stateLock.unlock()
63
+
64
+ guard let server = server, server.isRunning else {
65
+ return url
66
+ }
67
+
68
+ var allowed = CharacterSet.urlQueryAllowed
69
+ allowed.remove(charactersIn: "&#")
70
+ guard let encoded = url.addingPercentEncoding(withAllowedCharacters: allowed) else {
71
+ return url
72
+ }
73
+
74
+ return "http://127.0.0.1:\(port)/proxy?url=\(encoded)"
75
+ }
76
+
77
+ /// Heavy async method — file I/O runs on a background thread via Promise.async.
78
+ public func clearCache() throws -> Promise<Void> {
79
+ stateLock.lock()
80
+ let server = proxyServer
81
+ stateLock.unlock()
82
+
83
+ return Promise.async {
84
+ if let server = server {
85
+ server.clearCache()
86
+ } else {
87
+ VideoCacheStorage(maxCacheSize: 0).clearAll()
88
+ }
89
+ }
90
+ }
91
+
92
+ /// Prefetches an HLS stream into cache. Returns a taskId for cancellation.
93
+ /// Runs entirely in the background — JS thread returns immediately.
94
+ public func prefetch(url: String, segmentCount: Double?) throws -> String {
95
+ guard let parsedURL = URL(string: url) else {
96
+ throw NSError(
97
+ domain: "HlsCache",
98
+ code: 400,
99
+ userInfo: [NSLocalizedDescriptionKey: "Invalid URL: \(url)"]
100
+ )
101
+ }
102
+
103
+ stateLock.lock()
104
+ let server = proxyServer
105
+ stateLock.unlock()
106
+
107
+ // Reuse the server's storage if running, otherwise create a temporary one.
108
+ let storage = server?.storage ?? VideoCacheStorage(maxCacheSize: 1_073_741_824)
109
+ let count = segmentCount.map { Int($0) } ?? 3
110
+
111
+ return PrefetchManager.shared.prefetch(url: parsedURL, storage: storage, segmentCount: count)
112
+ }
113
+
114
+ public func cancelPrefetch(taskId: String) throws -> Void {
115
+ PrefetchManager.shared.cancel(taskId: taskId)
116
+ }
117
+
118
+ public func cancelAllPrefetch() throws -> Void {
119
+ PrefetchManager.shared.cancelAll()
120
+ }
121
+ }
@@ -0,0 +1,220 @@
1
+ import Foundation
2
+
3
+ /// Delegate protocol for receiving data transfer events from the NetworkDownloader.
4
+ protocol NetworkDownloaderDelegate: AnyObject {
5
+ func didReceiveResponse(task: NetworkTask, response: URLResponse)
6
+ func didReceiveData(task: NetworkTask, data: Data)
7
+ func didComplete(task: NetworkTask, error: Error?)
8
+ }
9
+
10
+ /// A robust download manager that handles concurrent HTTP range requests with prioritization.
11
+ ///
12
+ /// Uses a semaphore to limit concurrent heavy downloads (preventing socket exhaustion)
13
+ /// while allowing manifests and small files to bypass the queue for instant startup.
14
+ final class NetworkDownloader {
15
+
16
+ // MARK: - Shared Instance
17
+
18
+ static let shared = NetworkDownloader()
19
+
20
+ // MARK: - Properties
21
+
22
+ /// Actor-isolated task registry — replaces NSLock + Dictionary on the hot didReceiveData path.
23
+ private let router = SessionRouter()
24
+
25
+ /// Limits concurrent heavy segment downloads to prevent OS-level connection refusals.
26
+ private let semaphore = DispatchSemaphore(value: 32)
27
+
28
+ /// Serial queue: ensures FIFO for heavy tasks and prevents thread explosion.
29
+ private let queue = DispatchQueue(label: "com.videocache.downloader")
30
+
31
+ private lazy var session: URLSession = {
32
+ let config = URLSessionConfiguration.default
33
+ config.timeoutIntervalForRequest = 60.0
34
+ config.httpMaximumConnectionsPerHost = 32
35
+ return URLSession(configuration: config, delegate: router, delegateQueue: nil)
36
+ }()
37
+
38
+ // MARK: - API
39
+
40
+ /// Fetches a manifest (m3u8) using the managed session and returns the full data via callback.
41
+ /// Bypasses the semaphore — manifests are always high-priority.
42
+ @discardableResult
43
+ func downloadManifest(url: URL, completion: @escaping (Data?, Error?) -> Void) -> NetworkTask {
44
+ var request = URLRequest(url: url)
45
+ request.cachePolicy = .reloadIgnoringLocalCacheData
46
+
47
+ let dataTask = session.dataTask(with: request)
48
+ let task = NetworkTask(dataTask: dataTask, delegate: nil, url: url.absoluteString)
49
+ task.setManifestCompletion(completion)
50
+
51
+ router.register(task: task)
52
+ dataTask.resume()
53
+ return task
54
+ }
55
+
56
+ /// Initiates a streaming download for a segment or other resource.
57
+ func download(url: URL, range: Range<Int>?, delegate: NetworkDownloaderDelegate) -> NetworkTask {
58
+ var request = URLRequest(url: url)
59
+ request.cachePolicy = .reloadIgnoringLocalCacheData
60
+
61
+ if let range = range {
62
+ let end = (range.upperBound == Int.max) ? "" : "\(range.upperBound - 1)"
63
+ request.addValue("bytes=\(range.lowerBound)-\(end)", forHTTPHeaderField: "Range")
64
+ }
65
+
66
+ let dataTask = session.dataTask(with: request)
67
+ let task = NetworkTask(dataTask: dataTask, delegate: delegate, url: url.absoluteString)
68
+
69
+ let urlString = url.absoluteString.lowercased()
70
+ var isPriority = url.pathExtension.lowercased() == "m3u8"
71
+ || urlString.contains(".m3u8")
72
+ || urlString.contains("init.mp4")
73
+ if let r = range, (r.upperBound - r.lowerBound) < 1024 { isPriority = true }
74
+
75
+ router.register(task: task)
76
+
77
+ if isPriority {
78
+ dataTask.resume()
79
+ } else {
80
+ queue.async {
81
+ task.setOnComplete { [weak self] in self?.semaphore.signal() }
82
+ self.semaphore.wait()
83
+ if task.dataTask.state == .canceling || task.dataTask.state == .completed {
84
+ task.finish()
85
+ return
86
+ }
87
+ dataTask.resume()
88
+ }
89
+ }
90
+
91
+ return task
92
+ }
93
+ }
94
+
95
+ // MARK: - NetworkTask
96
+
97
+ final class NetworkTask: Hashable {
98
+
99
+ let id = UUID()
100
+ let dataTask: URLSessionDataTask
101
+ weak var delegate: NetworkDownloaderDelegate?
102
+ let urlString: String
103
+
104
+ private let lock = NSLock()
105
+ private var onComplete: (() -> Void)?
106
+
107
+ /// Accumulator + callback for manifest (full-body) downloads.
108
+ private var manifestBuffer = Data()
109
+ private var manifestCompletion: ((Data?, Error?) -> Void)?
110
+
111
+ init(dataTask: URLSessionDataTask, delegate: NetworkDownloaderDelegate?, url: String) {
112
+ self.dataTask = dataTask
113
+ self.delegate = delegate
114
+ self.urlString = url
115
+ }
116
+
117
+ func setOnComplete(_ block: @escaping () -> Void) {
118
+ lock.lock(); defer { lock.unlock() }
119
+ onComplete = block
120
+ }
121
+
122
+ func setManifestCompletion(_ block: @escaping (Data?, Error?) -> Void) {
123
+ lock.lock(); defer { lock.unlock() }
124
+ manifestCompletion = block
125
+ }
126
+
127
+ /// Appends data for manifest accumulation.
128
+ func appendManifestData(_ data: Data) {
129
+ lock.lock(); defer { lock.unlock() }
130
+ manifestBuffer.append(data)
131
+ }
132
+
133
+ /// Fires the manifest completion handler exactly once.
134
+ func finishManifest(error: Error?) {
135
+ lock.lock()
136
+ let completion = manifestCompletion
137
+ let buffer = manifestBuffer
138
+ manifestCompletion = nil
139
+ lock.unlock()
140
+
141
+ completion?(error == nil ? buffer : nil, error)
142
+ }
143
+
144
+ func finish() {
145
+ lock.lock(); defer { lock.unlock() }
146
+ onComplete?()
147
+ onComplete = nil
148
+ }
149
+
150
+ func cancel() {
151
+ dataTask.cancel()
152
+ finish()
153
+ }
154
+
155
+ static func == (lhs: NetworkTask, rhs: NetworkTask) -> Bool { lhs.id == rhs.id }
156
+ func hash(into hasher: inout Hasher) { hasher.combine(id) }
157
+ }
158
+
159
+ // MARK: - SessionRouter
160
+
161
+ /// URLSession delegate that maps callbacks to NetworkTask instances.
162
+ ///
163
+ /// Uses NSLock for thread safety — URLSession delegate methods are called on URLSession's
164
+ /// internal dispatch queue (not a Swift async context), so NSLock is valid here.
165
+ private final class SessionRouter: NSObject, URLSessionDataDelegate {
166
+
167
+ private var tasks = [Int: NetworkTask]()
168
+ private let lock = NSLock()
169
+
170
+ func register(task: NetworkTask) {
171
+ lock.lock()
172
+ tasks[task.dataTask.taskIdentifier] = task
173
+ lock.unlock()
174
+ }
175
+
176
+ private func unregister(taskIdentifier: Int) -> NetworkTask? {
177
+ lock.lock()
178
+ defer { lock.unlock() }
179
+ return tasks.removeValue(forKey: taskIdentifier)
180
+ }
181
+
182
+ private func task(for identifier: Int) -> NetworkTask? {
183
+ lock.lock()
184
+ defer { lock.unlock() }
185
+ return tasks[identifier]
186
+ }
187
+
188
+ // MARK: - URLSessionDataDelegate
189
+
190
+ func urlSession(
191
+ _ session: URLSession,
192
+ dataTask: URLSessionDataTask,
193
+ didReceive response: URLResponse,
194
+ completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
195
+ ) {
196
+ if let task = task(for: dataTask.taskIdentifier) {
197
+ task.delegate?.didReceiveResponse(task: task, response: response)
198
+ }
199
+ completionHandler(.allow)
200
+ }
201
+
202
+ func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
203
+ guard let task = task(for: dataTask.taskIdentifier) else { return }
204
+ if task.delegate != nil {
205
+ task.delegate?.didReceiveData(task: task, data: data)
206
+ } else {
207
+ task.appendManifestData(data)
208
+ }
209
+ }
210
+
211
+ func urlSession(_ session: URLSession, task urlSessionTask: URLSessionTask, didCompleteWithError error: Error?) {
212
+ guard let networkTask = unregister(taskIdentifier: urlSessionTask.taskIdentifier) else { return }
213
+ if networkTask.delegate != nil {
214
+ networkTask.delegate?.didComplete(task: networkTask, error: error)
215
+ } else {
216
+ networkTask.finishManifest(error: error)
217
+ }
218
+ networkTask.finish()
219
+ }
220
+ }
@@ -0,0 +1,235 @@
1
+ import Foundation
2
+
3
+ /// Manages background prefetch tasks for HLS streams.
4
+ ///
5
+ /// Each prefetch task downloads the manifest + first N segments into the disk cache
6
+ /// so they are instantly served from cache when the video player requests them.
7
+ internal final class PrefetchManager {
8
+
9
+ static let shared = PrefetchManager()
10
+
11
+ private let lock = NSLock()
12
+ private var activeTasks: [String: PrefetchTask] = [:]
13
+
14
+ /// Starts prefetching an HLS stream. Returns a taskId that can be used to cancel.
15
+ func prefetch(url: URL, storage: VideoCacheStorage, segmentCount: Int) -> String {
16
+ let taskId = UUID().uuidString
17
+ let task = PrefetchTask(url: url, storage: storage, segmentCount: segmentCount)
18
+
19
+ lock.lock()
20
+ activeTasks[taskId] = task
21
+ lock.unlock()
22
+
23
+ task.start { [weak self] in
24
+ self?.lock.lock()
25
+ self?.activeTasks.removeValue(forKey: taskId)
26
+ self?.lock.unlock()
27
+ }
28
+
29
+ return taskId
30
+ }
31
+
32
+ func cancel(taskId: String) {
33
+ lock.lock()
34
+ let task = activeTasks.removeValue(forKey: taskId)
35
+ lock.unlock()
36
+ task?.cancel()
37
+ }
38
+
39
+ func cancelAll() {
40
+ lock.lock()
41
+ let tasks = Array(activeTasks.values)
42
+ activeTasks.removeAll()
43
+ lock.unlock()
44
+ tasks.forEach { $0.cancel() }
45
+ }
46
+ }
47
+
48
+ // MARK: - PrefetchTask
49
+
50
+ private final class PrefetchTask {
51
+
52
+ private let url: URL
53
+ private let storage: VideoCacheStorage
54
+ private let segmentCount: Int
55
+
56
+ private let lock = NSLock()
57
+ private var networkTasks: [NetworkTask] = []
58
+ private var isCancelled = false
59
+
60
+ init(url: URL, storage: VideoCacheStorage, segmentCount: Int) {
61
+ self.url = url
62
+ self.storage = storage
63
+ self.segmentCount = segmentCount
64
+ }
65
+
66
+ func start(onComplete: @escaping () -> Void) {
67
+ fetchManifest(url: url, onComplete: onComplete)
68
+ }
69
+
70
+ func cancel() {
71
+ lock.lock()
72
+ isCancelled = true
73
+ let tasks = networkTasks
74
+ networkTasks.removeAll()
75
+ lock.unlock()
76
+ tasks.forEach { $0.cancel() }
77
+ }
78
+
79
+ // MARK: - Private
80
+
81
+ private func fetchManifest(url: URL, onComplete: @escaping () -> Void) {
82
+ let task = NetworkDownloader.shared.downloadManifest(url: url) { [weak self] data, error in
83
+ guard let self = self, !self.isCancelled else { onComplete(); return }
84
+ guard let data = data, let content = String(data: data, encoding: .utf8) else {
85
+ onComplete(); return
86
+ }
87
+ self.storage.save(data: data, for: url.absoluteString)
88
+ self.processManifest(content: content, baseURL: url, onComplete: onComplete)
89
+ }
90
+ addNetworkTask(task)
91
+ }
92
+
93
+ private func processManifest(content: String, baseURL: URL, onComplete: @escaping () -> Void) {
94
+ if content.contains("#EXT-X-STREAM-INF") {
95
+ // Master playlist — cache ALL variant manifests so any quality AVPlayer picks is available.
96
+ let variantURLs = parseAllVariantURLs(content: content, baseURL: baseURL)
97
+ guard !variantURLs.isEmpty else { onComplete(); return }
98
+
99
+ let group = DispatchGroup()
100
+ for variantURL in variantURLs {
101
+ guard !isCancelled else { break }
102
+ group.enter()
103
+ fetchManifest(url: variantURL, onComplete: { group.leave() })
104
+ }
105
+ group.notify(queue: .global(qos: .background)) { onComplete() }
106
+ } else {
107
+ // Media playlist — download init segment (if any) + first N segments.
108
+ var urls: [URL] = []
109
+ if let initURL = parseInitSegmentURL(content: content, baseURL: baseURL) {
110
+ urls.append(initURL)
111
+ }
112
+ urls.append(contentsOf: parseSegmentURLs(content: content, baseURL: baseURL))
113
+ downloadSegments(urls: urls, onComplete: onComplete)
114
+ }
115
+ }
116
+
117
+ private func downloadSegments(urls: [URL], onComplete: @escaping () -> Void) {
118
+ guard !urls.isEmpty else { onComplete(); return }
119
+
120
+ let group = DispatchGroup()
121
+
122
+ for url in urls {
123
+ guard !isCancelled else { break }
124
+ if storage.exists(for: url.absoluteString) { continue }
125
+
126
+ group.enter()
127
+ let delegate = SegmentAccumulator(storage: storage, url: url) {
128
+ group.leave()
129
+ }
130
+ let task = NetworkDownloader.shared.download(url: url, range: nil, delegate: delegate)
131
+ addNetworkTask(task)
132
+ }
133
+
134
+ group.notify(queue: .global(qos: .background)) { [weak self] in
135
+ _ = self // retain until done
136
+ onComplete()
137
+ }
138
+ }
139
+
140
+ private func addNetworkTask(_ task: NetworkTask) {
141
+ lock.lock()
142
+ networkTasks.append(task)
143
+ lock.unlock()
144
+ }
145
+
146
+ // MARK: - HLS Parsing
147
+
148
+ /// Returns ALL variant/rendition playlist URLs from a master playlist.
149
+ /// Caching every variant ensures AVPlayer can pick any quality while offline.
150
+ private func parseAllVariantURLs(content: String, baseURL: URL) -> [URL] {
151
+ var urls: [URL] = []
152
+ let lines = content.components(separatedBy: .newlines)
153
+ var i = 0
154
+ while i < lines.count {
155
+ let line = lines[i].trimmingCharacters(in: .whitespaces)
156
+ if line.hasPrefix("#EXT-X-STREAM-INF") {
157
+ let nextIndex = i + 1
158
+ if nextIndex < lines.count {
159
+ let urlLine = lines[nextIndex].trimmingCharacters(in: .whitespaces)
160
+ if !urlLine.isEmpty, !urlLine.hasPrefix("#"),
161
+ let resolved = URL(string: urlLine, relativeTo: baseURL)?.absoluteURL {
162
+ urls.append(resolved)
163
+ }
164
+ }
165
+ }
166
+ i += 1
167
+ }
168
+ return urls
169
+ }
170
+
171
+ /// Parses the `#EXT-X-MAP` init segment URI from a media playlist.
172
+ /// fMP4 streams require the init segment for every quality level.
173
+ private func parseInitSegmentURL(content: String, baseURL: URL) -> URL? {
174
+ for line in content.components(separatedBy: .newlines) {
175
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
176
+ guard trimmed.hasPrefix("#EXT-X-MAP:") else { continue }
177
+ if let uriStart = trimmed.range(of: "URI=\""),
178
+ let uriEnd = trimmed.range(of: "\"", range: uriStart.upperBound..<trimmed.endIndex) {
179
+ let uri = String(trimmed[uriStart.upperBound..<uriEnd.lowerBound])
180
+ return URL(string: uri, relativeTo: baseURL)?.absoluteURL
181
+ }
182
+ }
183
+ return nil
184
+ }
185
+
186
+ /// Parses up to `segmentCount` media segment URLs from a media playlist.
187
+ private func parseSegmentURLs(content: String, baseURL: URL) -> [URL] {
188
+ var urls: [URL] = []
189
+ for line in content.components(separatedBy: .newlines) {
190
+ guard urls.count < segmentCount else { break }
191
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
192
+ guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue }
193
+ if let resolved = URL(string: trimmed, relativeTo: baseURL)?.absoluteURL {
194
+ urls.append(resolved)
195
+ }
196
+ }
197
+ return urls
198
+ }
199
+ }
200
+
201
+ // MARK: - SegmentAccumulator
202
+
203
+ /// Accumulates streaming segment data and saves it to cache on completion.
204
+ private final class SegmentAccumulator: NetworkDownloaderDelegate {
205
+
206
+ private let storage: VideoCacheStorage
207
+ private let url: URL
208
+ private let onComplete: () -> Void
209
+ private var buffer = Data()
210
+ private let lock = NSLock()
211
+
212
+ init(storage: VideoCacheStorage, url: URL, onComplete: @escaping () -> Void) {
213
+ self.storage = storage
214
+ self.url = url
215
+ self.onComplete = onComplete
216
+ }
217
+
218
+ func didReceiveResponse(task: NetworkTask, response: URLResponse) {}
219
+
220
+ func didReceiveData(task: NetworkTask, data: Data) {
221
+ lock.lock()
222
+ buffer.append(data)
223
+ lock.unlock()
224
+ }
225
+
226
+ func didComplete(task: NetworkTask, error: Error?) {
227
+ if error == nil {
228
+ lock.lock()
229
+ let data = buffer
230
+ lock.unlock()
231
+ storage.save(data: data, for: url.absoluteString)
232
+ }
233
+ onComplete()
234
+ }
235
+ }