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.
- package/HlsCache.podspec +29 -0
- package/LICENSE +20 -0
- package/README.md +233 -0
- package/android/CMakeLists.txt +24 -0
- package/android/build.gradle +118 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/cpp-adapter.cpp +6 -0
- package/android/src/main/java/com/margelo/nitro/hlscache/HlsCache.kt +32 -0
- package/android/src/main/java/com/margelo/nitro/hlscache/HlsCachePackage.kt +22 -0
- package/ios/ClientConnectionHandler.swift +234 -0
- package/ios/DataSource.swift +394 -0
- package/ios/HlsCache.swift +121 -0
- package/ios/NetworkDownloader.swift +220 -0
- package/ios/PrefetchManager.swift +235 -0
- package/ios/VideoCacheStorage.swift +235 -0
- package/ios/VideoProxyServer.swift +185 -0
- package/lib/module/HlsCache.nitro.js +4 -0
- package/lib/module/HlsCache.nitro.js.map +1 -0
- package/lib/module/index.js +71 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/HlsCache.nitro.d.ts +13 -0
- package/lib/typescript/src/HlsCache.nitro.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +36 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/nitro.json +23 -0
- package/nitrogen/generated/android/c++/JHybridHlsCacheSpec.cpp +89 -0
- package/nitrogen/generated/android/c++/JHybridHlsCacheSpec.hpp +68 -0
- package/nitrogen/generated/android/hlscache+autolinking.cmake +81 -0
- package/nitrogen/generated/android/hlscache+autolinking.gradle +27 -0
- package/nitrogen/generated/android/hlscacheOnLoad.cpp +54 -0
- package/nitrogen/generated/android/hlscacheOnLoad.hpp +34 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/hlscache/HybridHlsCacheSpec.kt +75 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/hlscache/hlscacheOnLoad.kt +35 -0
- package/nitrogen/generated/ios/HlsCache+autolinking.rb +60 -0
- package/nitrogen/generated/ios/HlsCache-Swift-Cxx-Bridge.cpp +49 -0
- package/nitrogen/generated/ios/HlsCache-Swift-Cxx-Bridge.hpp +160 -0
- package/nitrogen/generated/ios/HlsCache-Swift-Cxx-Umbrella.hpp +46 -0
- package/nitrogen/generated/ios/HlsCacheAutolinking.mm +33 -0
- package/nitrogen/generated/ios/HlsCacheAutolinking.swift +26 -0
- package/nitrogen/generated/ios/c++/HybridHlsCacheSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridHlsCacheSpecSwift.hpp +118 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridHlsCacheSpec.swift +60 -0
- package/nitrogen/generated/ios/swift/HybridHlsCacheSpec_cxx.swift +237 -0
- package/nitrogen/generated/shared/c++/HybridHlsCacheSpec.cpp +26 -0
- package/nitrogen/generated/shared/c++/HybridHlsCacheSpec.hpp +69 -0
- package/package.json +174 -0
- package/src/HlsCache.nitro.ts +15 -0
- 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
|
+
}
|