add native stream seek cache proxy

This commit is contained in:
dirtydishes 2026-05-25 16:22:55 -04:00
parent 4815c3a7f6
commit bccae25937
8 changed files with 849 additions and 2 deletions

View file

@ -38,3 +38,4 @@
{"id":"int-697dc66d","kind":"field_change","created_at":"2026-05-25T17:01:32.697187Z","actor":"dirtydishes","issue_id":"dreamio-0lt","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"not implementing now; user asked only to move previous work to the audio-track-selection branch"}}
{"id":"int-c9b3bcd7","kind":"field_change","created_at":"2026-05-25T17:48:09.142384Z","actor":"dirtydishes","issue_id":"dreamio-ejh","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by preserving known external subtitle display names for generic VLC subtitle tracks and expanding language-code aliases."}}
{"id":"int-12bf46aa","kind":"field_change","created_at":"2026-05-25T18:31:50.873069Z","actor":"dirtydishes","issue_id":"dreamio-kdf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tracked Xcode user interface state files were removed from the git index, and existing ignore rules now cover regenerated xcuserdata files."}}
{"id":"int-c15b9cb9","kind":"field_change","created_at":"2026-05-25T19:07:23.629637Z","actor":"dirtydishes","issue_id":"dreamio-3yb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added centralized 30-second VLC media caching for native playback and validated the iOS build."}}

View file

@ -24,6 +24,7 @@
{"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-3yb","title":"Add VLC seek buffer for native playback","description":"Configure balanced VLC media caching in the native playback backend so short seek jumps are less likely to feel like stream restarts while preserving existing playback controls, audio tracks, and subtitles.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T19:05:27Z","created_by":"dirtydishes","updated_at":"2026-05-25T19:07:24Z","started_at":"2026-05-25T19:05:31Z","closed_at":"2026-05-25T19:07:24Z","close_reason":"Added centralized 30-second VLC media caching for native playback and validated the iOS build.","comments":[{"id":"019e60b6-9b11-7c5f-af57-c9e54ac6129f","issue_id":"dreamio-3yb","author":"dirtydishes","text":"Device testing still shows repeated VLC buffering after 15-second jumps. Added DEBUG playback snapshots on state changes and delayed post-jump probes so the next pass can distinguish a stalled stream/range reconnect from a seek-state issue.","created_at":"2026-05-25T19:57:21Z"},{"id":"019e60c4-f824-79fe-b974-9fbe9fe91788","issue_id":"dreamio-3yb","author":"dirtydishes","text":"Latest device logs show VLC remains at the pre-jump time/position after a fixed skip while buffering. Added backend-only stalled jump recovery: after a short no-progress buffering window, reopen the same media with :start-time set to the requested target and reattach accepted subtitle candidates plus selected tracks where available.","created_at":"2026-05-25T20:13:02Z"}],"dependency_count":0,"dependent_count":0,"comment_count":2}
{"_type":"issue","id":"dreamio-kdf","title":"Stop tracking Xcode user state","description":"Xcode user interface state files are machine-specific and currently tracked, which causes noisy local modifications and pull conflicts. Remove tracked xcuserstate files from the git index while keeping ignore rules in place.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T18:31:36Z","created_by":"dirtydishes","updated_at":"2026-05-25T18:31:51Z","started_at":"2026-05-25T18:31:39Z","closed_at":"2026-05-25T18:31:51Z","close_reason":"Tracked Xcode user interface state files were removed from the git index, and existing ignore rules now cover regenerated xcuserdata files.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-0lt","title":"add audio track selection","description":"Add native player support for viewing and switching available audio tracks during playback.","status":"closed","priority":2,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-25T17:00:53Z","created_by":"dirtydishes","updated_at":"2026-05-25T17:01:33Z","closed_at":"2026-05-25T17:01:33Z","close_reason":"not implementing now; user asked only to move previous work to the audio-track-selection branch","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-2ju","title":"Show OpenSubtitles languages in caption tracks","description":"Preserve external subtitle metadata after VLC attaches OpenSubtitles tracks so the captions menu shows useful language labels instead of generic VLC track names.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:52:44Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:54:58Z","started_at":"2026-05-25T16:52:49Z","closed_at":"2026-05-25T16:54:58Z","close_reason":"Fixed by preserving OpenSubtitles subtitle display metadata through VLC external track attachment and adding display-name tests.","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -15,7 +15,8 @@
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */; };
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */; };
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */; };
BA013CEC876B829A86AE8DCB /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; };
6F2A2B522C00100100DREAMIO /* NativeStreamCacheProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B532C00100100DREAMIO /* NativeStreamCacheProxy.swift */; };
B6C42C187A771A50D200AD84 /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -29,6 +30,7 @@
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCNativePlaybackBackend.swift; sourceTree = "<group>"; };
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = "<group>"; };
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.swift; sourceTree = "<group>"; };
6F2A2B532C00100100DREAMIO /* NativeStreamCacheProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeStreamCacheProxy.swift; sourceTree = "<group>"; };
701702B9C2BFBEDE36E7F0A3 /* Pods-Dreamio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.release.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.release.xcconfig"; sourceTree = "<group>"; };
908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Dreamio.framework; sourceTree = BUILT_PRODUCTS_DIR; };
BF0A4D5BAC9400AEEF3B0181 /* Pods-Dreamio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.debug.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.debug.xcconfig"; sourceTree = "<group>"; };
@ -39,6 +41,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
B6C42C187A771A50D200AD84 /* Pods_Dreamio.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -91,6 +94,7 @@
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,
6F2A2B532C00100100DREAMIO /* NativeStreamCacheProxy.swift */,
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,
6F2A2B392C00100100DREAMIO /* Info.plist */,
);
@ -237,6 +241,7 @@
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */,
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */,
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */,
6F2A2B522C00100100DREAMIO /* NativeStreamCacheProxy.swift in Sources */,
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View file

@ -11,6 +11,7 @@ final class NativePlayerViewController: UIViewController {
private var attachedSubtitleURLs: Set<URL>
private var audioMenuSignature: String?
private var captionsMenuSignature: String?
private var streamCacheProxy: NativeStreamCacheProxy?
var onDismiss: (() -> Void)?
private let loadingView: UIActivityIndicatorView = {
@ -137,7 +138,8 @@ final class NativePlayerViewController: UIViewController {
configureBackend()
configureLayout()
startStartupTimer()
backend.play(request: request)
let playbackRequest = startProxyPlaybackRequest(for: request)
backend.play(request: playbackRequest)
addSubtitleCandidates(request.subtitleCandidates)
}
@ -192,9 +194,28 @@ final class NativePlayerViewController: UIViewController {
controlsTimer?.invalidate()
progressTimer?.invalidate()
backend.stop()
streamCacheProxy?.stop()
streamCacheProxy = nil
onDismiss?()
}
private func startProxyPlaybackRequest(for request: NativePlaybackRequest) -> NativePlaybackRequest {
guard request.playbackURL.scheme?.lowercased().hasPrefix("http") == true else {
return request
}
let proxy = NativeStreamCacheProxy(session: NativeStreamCacheProxy.Session(request: request))
do {
let proxyURL = try proxy.start()
streamCacheProxy = proxy
return request.withPlaybackURL(proxyURL)
} catch {
#if DEBUG
print("[DreamioStreamProxy] start-failed error=\(error.localizedDescription)")
#endif
return request
}
}
private func resolveSubtitleCandidates(_ candidates: [SubtitleCandidate]) async -> [SubtitleCandidate] {
var resolved: [SubtitleCandidate] = []
for candidate in candidates {

View file

@ -0,0 +1,399 @@
import Foundation
import Network
struct HTTPRange: Equatable {
let start: Int64
let end: Int64?
var headerValue: String {
if let end {
return "bytes=\(start)-\(end)"
}
return "bytes=\(start)-"
}
var length: Int64? {
guard let end else {
return nil
}
return max(0, end - start + 1)
}
static func parse(_ value: String?) -> HTTPRange? {
guard let value else {
return nil
}
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.lowercased().hasPrefix("bytes=") else {
return nil
}
let spec = String(trimmed.dropFirst(6))
guard !spec.contains(","), let dash = spec.firstIndex(of: "-") else {
return nil
}
let startPart = spec[..<dash]
let endPart = spec[spec.index(after: dash)...]
guard !startPart.isEmpty, let start = Int64(startPart), start >= 0 else {
return nil
}
if endPart.isEmpty {
return HTTPRange(start: start, end: nil)
}
guard let end = Int64(endPart), end >= start else {
return nil
}
return HTTPRange(start: start, end: end)
}
static func contentRange(start: Int64, end: Int64, totalLength: Int64?) -> String {
let total = totalLength.map(String.init) ?? "*"
return "bytes \(start)-\(end)/\(total)"
}
}
final class CachedRangeStore {
struct Lookup {
let data: Data
let isComplete: Bool
}
private struct Chunk {
let start: Int64
let end: Int64
let fileURL: URL
var lastAccess: Date
}
private let directory: URL
private let byteBudget: Int64
private let fileManager: FileManager
private var chunks: [Chunk] = []
init(sessionID: String, byteBudget: Int64, fileManager: FileManager = .default) {
self.byteBudget = byteBudget
self.fileManager = fileManager
directory = fileManager.temporaryDirectory
.appendingPathComponent("DreamioNativeStreamCache", isDirectory: true)
.appendingPathComponent(sessionID, isDirectory: true)
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
func lookup(range: HTTPRange, maximumLength: Int64) -> Lookup? {
let requestedEnd = range.end ?? range.start + maximumLength - 1
var cursor = range.start
var data = Data()
var touchedIndexes = Set<Int>()
for (index, chunk) in chunks.sorted(by: { $0.start < $1.start }).enumerated() {
guard chunk.end >= cursor, chunk.start <= cursor else {
continue
}
let readStart = max(cursor, chunk.start)
let readEnd = min(requestedEnd, chunk.end)
guard readEnd >= readStart,
let chunkData = try? Data(contentsOf: chunk.fileURL) else {
continue
}
let lower = Int(readStart - chunk.start)
let upper = Int(readEnd - chunk.start + 1)
data.append(chunkData.subdata(in: lower..<upper))
cursor = readEnd + 1
touchedIndexes.insert(index)
if cursor > requestedEnd {
break
}
}
guard !data.isEmpty else {
return nil
}
let now = Date()
for index in touchedIndexes where chunks.indices.contains(index) {
chunks[index].lastAccess = now
}
return Lookup(data: data, isComplete: cursor > requestedEnd)
}
func store(data: Data, start: Int64) {
guard !data.isEmpty else {
return
}
let end = start + Int64(data.count) - 1
let fileURL = directory.appendingPathComponent("\(start)-\(end).chunk")
do {
try data.write(to: fileURL, options: .atomic)
chunks.append(Chunk(start: start, end: end, fileURL: fileURL, lastAccess: Date()))
chunks.sort { $0.start < $1.start }
evictIfNeeded()
} catch {
#if DEBUG
print("[DreamioStreamProxy] cache-store-failed range=\(start)-\(end) error=\(error.localizedDescription)")
#endif
}
}
func evictKeepingBytesNear(offset: Int64) {
let lowerBound = max(0, offset - byteBudget)
let removed = chunks.filter { $0.end < lowerBound }
chunks.removeAll { $0.end < lowerBound }
removed.forEach { try? fileManager.removeItem(at: $0.fileURL) }
#if DEBUG
if !removed.isEmpty {
print("[DreamioStreamProxy] eviction removed=\(removed.count) lowerBound=\(lowerBound)")
}
#endif
evictIfNeeded()
}
func removeAll() {
try? fileManager.removeItem(at: directory)
chunks.removeAll()
}
private func evictIfNeeded() {
var total = chunks.reduce(Int64(0)) { $0 + ($1.end - $1.start + 1) }
guard total > byteBudget else {
return
}
for chunk in chunks.sorted(by: { $0.lastAccess < $1.lastAccess }) {
guard total > byteBudget else {
break
}
try? fileManager.removeItem(at: chunk.fileURL)
chunks.removeAll { $0.fileURL == chunk.fileURL }
total -= chunk.end - chunk.start + 1
#if DEBUG
print("[DreamioStreamProxy] eviction range=\(chunk.start)-\(chunk.end) remainingBytes=\(total)")
#endif
}
}
}
final class NativeStreamCacheProxy {
struct Session {
let id: String
let upstreamURL: URL
let headers: [String: String]
let referer: String
let userAgent: String?
let estimatedBitrate: Int64?
init(request: NativePlaybackRequest) {
id = UUID().uuidString
upstreamURL = request.playbackURL
headers = request.headers
referer = request.referer
userAgent = request.userAgent
estimatedBitrate = nil
}
}
private struct UpstreamResponse {
let statusCode: Int
let headers: [AnyHashable: Any]
let data: Data
}
private let session: Session
private let store: CachedRangeStore
private let fetchLength: Int64
private let prefetchLength: Int64
private var listener: NWListener?
private let queue = DispatchQueue(label: "dreamio.native-stream-cache-proxy")
init(session: Session, byteBudget: Int64? = nil, fetchLength: Int64 = 8 * 1024 * 1024) {
self.session = session
self.fetchLength = fetchLength
prefetchLength = fetchLength
let budget = byteBudget ?? max(30 * 1024 * 1024, (session.estimatedBitrate ?? 0) * 30 / 8)
store = CachedRangeStore(sessionID: session.id, byteBudget: budget)
}
func start() throws -> URL {
let listener = try NWListener(using: .tcp, on: .any)
self.listener = listener
listener.newConnectionHandler = { [weak self] connection in
self?.handle(connection: connection)
}
listener.start(queue: queue)
guard let port = listener.port else {
throw URLError(.cannotConnectToHost)
}
#if DEBUG
print("[DreamioStreamProxy] start port=\(port.rawValue) upstream=\(URLRedactor.redactedURLString(session.upstreamURL.absoluteString))")
#endif
return URL(string: "http://127.0.0.1:\(port.rawValue)/stream/\(session.id)")!
}
func stop() {
#if DEBUG
print("[DreamioStreamProxy] stop session=\(session.id)")
#endif
listener?.cancel()
listener = nil
store.removeAll()
}
func upstreamRequest(for range: HTTPRange?) -> URLRequest {
var request = URLRequest(url: session.upstreamURL)
for (key, value) in session.headers {
request.setValue(value, forHTTPHeaderField: key)
}
request.setValue(session.referer, forHTTPHeaderField: "Referer")
if let userAgent = session.userAgent {
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
}
if let range {
request.setValue(range.headerValue, forHTTPHeaderField: "Range")
}
return request
}
static func responseStatusForUpstreamStatus(_ statusCode: Int) -> Int {
statusCode == 206 ? 206 : statusCode
}
private func handle(connection: NWConnection) {
connection.start(queue: queue)
connection.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) { [weak self] data, _, _, _ in
guard let self, let data, let request = HTTPRequest(data: data) else {
connection.cancel()
return
}
let range = HTTPRange.parse(request.headers["range"])
self.respond(to: range, on: connection)
}
}
private func respond(to requestedRange: HTTPRange?, on connection: NWConnection) {
let range = requestedRange ?? HTTPRange(start: 0, end: fetchLength - 1)
let maximumLength = range.length ?? fetchLength
if let lookup = store.lookup(range: range, maximumLength: maximumLength), lookup.isComplete {
#if DEBUG
print("[DreamioStreamProxy] range-hit range=\(range.headerValue) bytes=\(lookup.data.count)")
#endif
send(data: lookup.data, statusCode: 206, rangeStart: range.start, totalLength: nil, contentType: nil, on: connection)
prefetch(after: range.start + Int64(lookup.data.count))
return
}
#if DEBUG
print("[DreamioStreamProxy] range-miss range=\(range.headerValue)")
#endif
let fetchRange = HTTPRange(start: range.start, end: range.start + maximumLength - 1)
guard let response = fetch(range: fetchRange) else {
sendStatus(502, on: connection)
return
}
#if DEBUG
print("[DreamioStreamProxy] upstream status=\(response.statusCode) range=\(fetchRange.headerValue)")
#endif
let responseStatus = Self.responseStatusForUpstreamStatus(response.statusCode)
if responseStatus == 206 {
store.store(data: response.data, start: range.start)
store.evictKeepingBytesNear(offset: range.start)
let contentType = response.headers["Content-Type"] as? String
let totalLength = totalLength(from: response.headers["Content-Range"] as? String)
send(data: response.data, statusCode: 206, rangeStart: range.start, totalLength: totalLength, contentType: contentType, on: connection)
prefetch(after: range.start + Int64(response.data.count))
} else {
#if DEBUG
print("[DreamioStreamProxy] pass-through fallback reason=upstream-returned-\(response.statusCode)")
#endif
send(data: response.data, statusCode: response.statusCode, headers: response.headers, on: connection)
}
}
private func prefetch(after offset: Int64) {
let range = HTTPRange(start: offset, end: offset + prefetchLength - 1)
queue.async { [weak self] in
guard let self, self.store.lookup(range: range, maximumLength: self.prefetchLength)?.isComplete != true else {
return
}
guard let response = self.fetch(range: range), response.statusCode == 206 else {
return
}
self.store.store(data: response.data, start: offset)
}
}
private func fetch(range: HTTPRange) -> UpstreamResponse? {
let semaphore = DispatchSemaphore(value: 0)
var result: UpstreamResponse?
URLSession.shared.dataTask(with: upstreamRequest(for: range)) { data, response, _ in
if let http = response as? HTTPURLResponse, let data {
result = UpstreamResponse(statusCode: http.statusCode, headers: http.allHeaderFields, data: data)
}
semaphore.signal()
}.resume()
semaphore.wait()
return result
}
private func send(data: Data, statusCode: Int, rangeStart: Int64, totalLength: Int64?, contentType: String?, on connection: NWConnection) {
let end = rangeStart + Int64(data.count) - 1
var headers = [
"Content-Length": "\(data.count)",
"Content-Range": HTTPRange.contentRange(start: rangeStart, end: end, totalLength: totalLength),
"Accept-Ranges": "bytes",
"Connection": "close"
]
if let contentType {
headers["Content-Type"] = contentType
}
send(statusCode: statusCode, headers: headers, body: data, on: connection)
}
private func send(data: Data, statusCode: Int, headers upstreamHeaders: [AnyHashable: Any], on connection: NWConnection) {
var headers = [
"Content-Length": "\(data.count)",
"Accept-Ranges": "none",
"Connection": "close"
]
if let contentType = upstreamHeaders["Content-Type"] as? String {
headers["Content-Type"] = contentType
}
send(statusCode: statusCode, headers: headers, body: data, on: connection)
}
private func sendStatus(_ statusCode: Int, on connection: NWConnection) {
send(statusCode: statusCode, headers: ["Content-Length": "0", "Connection": "close"], body: Data(), on: connection)
}
private func send(statusCode: Int, headers: [String: String], body: Data, on connection: NWConnection) {
let reason = statusCode == 206 ? "Partial Content" : statusCode == 200 ? "OK" : "Bad Gateway"
let headerLines = headers.map { "\($0.key): \($0.value)" }.joined(separator: "\r\n")
var response = Data("HTTP/1.1 \(statusCode) \(reason)\r\n\(headerLines)\r\n\r\n".utf8)
response.append(body)
connection.send(content: response, completion: .contentProcessed { _ in connection.cancel() })
}
private func totalLength(from contentRange: String?) -> Int64? {
guard let contentRange, let slash = contentRange.lastIndex(of: "/") else {
return nil
}
let value = contentRange[contentRange.index(after: slash)...]
return Int64(value)
}
}
private struct HTTPRequest {
let headers: [String: String]
init?(data: Data) {
guard let string = String(data: data, encoding: .utf8),
let headerBlock = string.components(separatedBy: "\r\n\r\n").first else {
return nil
}
var headers: [String: String] = [:]
for line in headerBlock.components(separatedBy: "\r\n").dropFirst() {
guard let separator = line.firstIndex(of: ":") else {
continue
}
let key = line[..<separator].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let value = line[line.index(after: separator)...].trimmingCharacters(in: .whitespacesAndNewlines)
headers[key] = value
}
self.headers = headers
}
}

View file

@ -27,6 +27,20 @@ struct NativePlaybackRequest {
let headers: [String: String]
let classification: StreamClassification
let subtitleCandidates: [SubtitleCandidate]
func withPlaybackURL(_ playbackURL: URL) -> NativePlaybackRequest {
NativePlaybackRequest(
playbackURL: playbackURL,
observedURL: observedURL,
resolverURL: resolverURL,
pageURL: pageURL,
userAgent: userAgent,
referer: referer,
headers: headers,
classification: classification,
subtitleCandidates: subtitleCandidates
)
}
}
struct SubtitleCandidate: Equatable {

View file

@ -24,6 +24,12 @@ struct StreamResolverTests {
testSubtitleDisplayNameNormalization()
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
testSubtitleOptionMappingIncludesNone()
testHTTPRangeParsing()
testContentRangeFormatting()
testCacheLookupAcrossChunkBoundaries()
testCacheEvictionOutsideByteBudget()
testProxyForwardsUpstreamHeaders()
testProxyPassThroughFallbackStatus()
print("StreamResolverTests passed")
}
@ -40,6 +46,85 @@ struct StreamResolverTests {
assertEqual(request.headers["User-Agent"], "DreamioTest/1")
}
private static func testHTTPRangeParsing() {
assertEqual(HTTPRange.parse("bytes=10-20"), HTTPRange(start: 10, end: 20))
assertEqual(HTTPRange.parse("bytes=10-"), HTTPRange(start: 10, end: nil))
assert(HTTPRange.parse("items=10-20") == nil, "Expected non-byte range to be rejected")
assert(HTTPRange.parse("bytes=-20") == nil, "Expected suffix ranges to be rejected for v1")
assert(HTTPRange.parse("bytes=20-10") == nil, "Expected inverted ranges to be rejected")
assert(HTTPRange.parse("bytes=1-2,3-4") == nil, "Expected multipart ranges to be rejected")
}
private static func testContentRangeFormatting() {
assertEqual(HTTPRange.contentRange(start: 10, end: 20, totalLength: 100), "bytes 10-20/100")
assertEqual(HTTPRange.contentRange(start: 10, end: 20, totalLength: nil), "bytes 10-20/*")
}
private static func testCacheLookupAcrossChunkBoundaries() {
let store = CachedRangeStore(sessionID: "test-\(UUID().uuidString)", byteBudget: 1024)
defer { store.removeAll() }
store.store(data: Data("abc".utf8), start: 0)
store.store(data: Data("def".utf8), start: 3)
let lookup = store.lookup(range: HTTPRange(start: 0, end: 5), maximumLength: 6)
assertEqual(String(data: lookup?.data ?? Data(), encoding: .utf8), "abcdef")
assertEqual(lookup?.isComplete, true)
}
private static func testCacheEvictionOutsideByteBudget() {
let store = CachedRangeStore(sessionID: "test-\(UUID().uuidString)", byteBudget: 6)
defer { store.removeAll() }
store.store(data: Data("abcdef".utf8), start: 0)
store.store(data: Data("ghijkl".utf8), start: 6)
let oldLookup = store.lookup(range: HTTPRange(start: 0, end: 5), maximumLength: 6)
let newLookup = store.lookup(range: HTTPRange(start: 6, end: 11), maximumLength: 6)
assert(oldLookup == nil, "Expected old chunk to be evicted outside the byte budget")
assertEqual(String(data: newLookup?.data ?? Data(), encoding: .utf8), "ghijkl")
}
private static func testProxyForwardsUpstreamHeaders() {
let proxy = NativeStreamCacheProxy(session: NativeStreamCacheProxy.Session(request: proxyTestRequest()))
let upstreamRequest = proxy.upstreamRequest(for: HTTPRange(start: 12, end: 34))
assertEqual(upstreamRequest.value(forHTTPHeaderField: "Range"), "bytes=12-34")
assertEqual(upstreamRequest.value(forHTTPHeaderField: "Referer"), "https://resolver.example.test/")
assertEqual(upstreamRequest.value(forHTTPHeaderField: "User-Agent"), "DreamioTest/1")
assertEqual(upstreamRequest.value(forHTTPHeaderField: "Authorization"), "Bearer secret")
}
private static func testProxyPassThroughFallbackStatus() {
assertEqual(NativeStreamCacheProxy.responseStatusForUpstreamStatus(206), 206)
assertEqual(NativeStreamCacheProxy.responseStatusForUpstreamStatus(200), 200)
}
private static func proxyTestRequest() -> NativePlaybackRequest {
NativePlaybackRequest(
playbackURL: URL(string: "https://cdn.example.test/movie.mkv")!,
observedURL: URL(string: "https://cdn.example.test/movie.mkv")!,
resolverURL: URL(string: "https://resolver.example.test/play")!,
pageURL: nil,
userAgent: "DreamioTest/1",
referer: "https://resolver.example.test/",
headers: [
"Referer": "https://resolver.example.test/",
"User-Agent": "DreamioTest/1",
"Authorization": "Bearer secret"
],
classification: StreamClassification(
sourceKind: .directFile,
containerGuess: .mkv,
reason: "test",
shouldIntercept: true,
sanitizedObservedURL: "https://cdn.example.test/movie.mkv",
sanitizedResolverURL: nil
),
subtitleCandidates: []
)
}
private static func testResolverSelectsUnsupportedDirectURLAndHeaders() {
let payload: [String: Any] = [
"streams": [

File diff suppressed because one or more lines are too long