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,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
|
+
}
|