Local Seek Buffer for VLC Playback
Added a local HTTP range proxy/cache for native VLC playback so direct-file streams can reuse recently fetched bytes for short rewinds and opportunistically warm nearby forward ranges.
Summary
Dreamio now starts a per-playback loopback proxy before handing a direct HTTP stream to VLC. VLC receives a localhost URL, while the proxy forwards authenticated byte-range requests upstream, stores bounded temporary chunks, serves complete range hits locally, and cleans up the cache when the native player dismisses.
Changes Made
- Added
NativeStreamCacheProxy,NativeStreamCacheProxy.Session,CachedRangeStore, andHTTPRange. - Started the proxy from
NativePlayerViewControllerbeforebackend.play(request:)and replaced onlyplaybackURLwith the local stream URL. - Kept subtitle candidates and subtitle attachment untouched, so existing external subtitle URLs still flow directly to VLC.
- Added
NativePlaybackRequest.withPlaybackURL(_:)to derive the VLC-facing request without rebuilding headers or metadata by hand. - Added test coverage for range parsing, content range formatting, chunk lookup, eviction, forwarded headers, and fallback status behavior.
- Registered the new Swift source file in the Xcode project.
Context
VLC was previously opened directly on debrid or direct-file URLs. That means small seeks depended entirely on VLC and the upstream server. The new proxy gives Dreamio a narrow local buffer for HTTP/HTTPS direct-file streams without changing Stremio interception or subtitle discovery contracts.
Important Implementation Details
- The proxy listens on
127.0.0.1usingNWListenerand exposes/stream/<session id>. - Upstream requests preserve
Referer,User-Agent, and all request headers fromNativePlaybackRequest, plus the VLC-requestedRange. - Successful upstream
206responses are stored as temp chunk files underFileManager.default.temporaryDirectory/DreamioNativeStreamCache/<session id>. - Complete cache hits return
206 Partial ContentwithContent-Range,Content-Length, andAccept-Ranges. - If upstream returns a non-range status such as
200, the proxy passes that body/status back and logs that instant nearby seek cannot be guaranteed. - The current v1 uses a conservative byte budget fallback and fixed range windows. Bitrate-derived sizing is scaffolded through the session but not yet estimated from media metadata.
Relevant Diff Snippets
Dreamio/NativePlayerViewController.swift
10 unmodified lines111213141516120 unmodified lines13713813914014114214348 unmodified lines19219319419519619719819920010 unmodified linesprivate var attachedSubtitleURLs: Set<URL>private var audioMenuSignature: String?private var captionsMenuSignature: String?var onDismiss: (() -> Void)?private let loadingView: UIActivityIndicatorView = {120 unmodified linesconfigureBackend()configureLayout()startStartupTimer()backend.play(request: request)addSubtitleCandidates(request.subtitleCandidates)}48 unmodified linescontrolsTimer?.invalidate()progressTimer?.invalidate()backend.stop()onDismiss?()}private func resolveSubtitleCandidates(_ candidates: [SubtitleCandidate]) async -> [SubtitleCandidate] {var resolved: [SubtitleCandidate] = []for candidate in candidates {10 unmodified lines11121314151617120 unmodified lines13813914014114214314414548 unmodified lines19419519619719819920020120220320420520620720820921021121221321421521621721821922022110 unmodified linesprivate var attachedSubtitleURLs: Set<URL>private var audioMenuSignature: String?private var captionsMenuSignature: String?private var streamCacheProxy: NativeStreamCacheProxy?var onDismiss: (() -> Void)?private let loadingView: UIActivityIndicatorView = {120 unmodified linesconfigureBackend()configureLayout()startStartupTimer()let playbackRequest = startProxyPlaybackRequest(for: request)backend.play(request: playbackRequest)addSubtitleCandidates(request.subtitleCandidates)}48 unmodified linescontrolsTimer?.invalidate()progressTimer?.invalidate()backend.stop()streamCacheProxy?.stop()streamCacheProxy = nilonDismiss?()}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 = proxyreturn request.withPlaybackURL(proxyURL)} catch {#if DEBUGprint("[DreamioStreamProxy] start-failed error=\(error.localizedDescription)")#endifreturn request}}private func resolveSubtitleCandidates(_ candidates: [SubtitleCandidate]) async -> [SubtitleCandidate] {var resolved: [SubtitleCandidate] = []for candidate in candidates {
Dreamio/StreamCandidate.swift
26 unmodified lines27282930313226 unmodified lineslet headers: [String: String]let classification: StreamClassificationlet subtitleCandidates: [SubtitleCandidate]}struct SubtitleCandidate: Equatable {26 unmodified lines272829303132333435363738394041424344454626 unmodified lineslet headers: [String: String]let classification: StreamClassificationlet 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 {
Tests/StreamResolverTests.swift
23 unmodified lines24252627282910 unmodified lines40414243444523 unmodified linestestSubtitleDisplayNameNormalization()testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()testSubtitleOptionMappingIncludesNone()print("StreamResolverTests passed")}10 unmodified linesassertEqual(request.headers["User-Agent"], "DreamioTest/1")}private static func testResolverSelectsUnsupportedDirectURLAndHeaders() {let payload: [String: Any] = ["streams": [23 unmodified lines24252627282930313233343510 unmodified lines46474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913023 unmodified linestestSubtitleDisplayNameNormalization()testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()testSubtitleOptionMappingIncludesNone()testHTTPRangeParsing()testContentRangeFormatting()testCacheLookupAcrossChunkBoundaries()testCacheEvictionOutsideByteBudget()testProxyForwardsUpstreamHeaders()testProxyPassThroughFallbackStatus()print("StreamResolverTests passed")}10 unmodified linesassertEqual(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": [
Dreamio.xcodeproj/project.pbxproj
14 unmodified lines151617181920217 unmodified lines2930313233344 unmodified lines39404142434446 unmodified lines919293949596140 unmodified lines23723823924024124214 unmodified lines6F2A2B442C00100100DREAMIO /* 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 */; };/* End PBXBuildFile section *//* Begin PBXFileReference section */7 unmodified lines6F2A2B482C00100100DREAMIO /* 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>"; };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>"; };4 unmodified linesisa = PBXFrameworksBuildPhase;buildActionMask = 2147483647;files = ();runOnlyForDeploymentPostprocessing = 0;};46 unmodified lines6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,6F2A2B392C00100100DREAMIO /* Info.plist */,);140 unmodified lines6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */,6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */,6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */,6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */,);runOnlyForDeploymentPostprocessing = 0;14 unmodified lines15161718192021227 unmodified lines303132333435364 unmodified lines4142434445464746 unmodified lines949596979899100140 unmodified lines24124224324424524624714 unmodified lines6F2A2B442C00100100DREAMIO /* 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 */; };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 */7 unmodified lines6F2A2B482C00100100DREAMIO /* 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>"; };4 unmodified linesisa = PBXFrameworksBuildPhase;buildActionMask = 2147483647;files = (B6C42C187A771A50D200AD84 /* Pods_Dreamio.framework in Frameworks */,);runOnlyForDeploymentPostprocessing = 0;};46 unmodified lines6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,6F2A2B532C00100100DREAMIO /* NativeStreamCacheProxy.swift */,6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,6F2A2B392C00100100DREAMIO /* Info.plist */,);140 unmodified lines6F2A2B502C00100100DREAMIO /* 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;
Expected Impact for End-Users
Short backward jumps should be more likely to resume quickly once playback has warmed the rolling cache. Short forward jumps can benefit from read-ahead when upstream speed allows. Seeking outside the retained window still falls back to upstream range fetching instead of wedging the player.
Validation
- Passed:
swiftc -parse-as-library Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/NativeStreamCacheProxy.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-resolver-tests && /tmp/dreamio-stream-resolver-tests. - Passed after dependency setup:
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build. - Setup note: the first build failed because local CocoaPods support files were missing. I ran
pod install, which restored untracked localPods/contents without changing tracked project files, then reran the build successfully. - Not performed: manual device validation with a real debrid stream was not available in this environment.
Issues, Limitations, and Mitigations
- v1 targets direct-file HTTP/HTTPS streams, not HLS playlists.
- The proxy only guarantees bytes it has actually fetched and retained. It cannot instantly serve unwatched earlier offsets.
- Forward read-ahead is best effort and bounded; slow upstream servers can still buffer.
- Upstream servers that ignore byte ranges fall back to pass-through, with debug logging to explain why local seeks are not guaranteed.
- The current listener implementation is intentionally small and should be stress-tested on device before widening the cache window or range concurrency.
Follow-up Work
- Create a device validation issue for real debrid MKV/MP4 seek behavior across short back, short forward, and far seeks.
- Estimate bitrate from duration/content length when available so the default cache window can more closely mean 30 seconds of media.
- Stream upstream range responses incrementally instead of buffering the whole fetched slice before replying.
- Add structured DEBUG counters for hit ratio, evictions, and upstream range latency.
New Changes as of May 25, 2026 at 5:55 PM
Summary of changes
Adjusted the local stream proxy after device logs showed VLC remained pinned in buffering after a +15 second jump. The proxy now handles VLC HTTP probes more normally and reduces the amount of upstream data it waits for before responding to seek reads.
Why this change was made
The buffering logs showed VLC stayed at the pre-jump timestamp even though the player reported a seekable stream. That pointed at the local HTTP proxy path: VLC can issue HEAD probes and multiple range reads, and the first version treated every request as a large ranged GET on the listener queue.
Code diffs
Dreamio/NativeStreamCacheProxy.swift
66 unmodified lines6768697071726 unmodified lines79808182838430 unmodified lines11511611711811912012 unmodified lines1331341351361371387 unmodified lines14614714814915015148 unmodified lines20020120220320420520620720820921049 unmodified lines26026126226326426526626726826927027120 unmodified lines2922932942952962972982994 unmodified lines3043053063073083098 unmodified lines31831932032132232332432532632732821 unmodified lines35035135235335435535611 unmodified lines3683693703713723734 unmodified lines3783793803813823831 unmodified line3853863873883893903913921 unmodified line39439539639739839966 unmodified linesprivate let directory: URLprivate let byteBudget: Int64private let fileManager: FileManagerprivate var chunks: [Chunk] = []init(sessionID: String, byteBudget: Int64, fileManager: FileManager = .default) {6 unmodified lines}func lookup(range: HTTPRange, maximumLength: Int64) -> Lookup? {let requestedEnd = range.end ?? range.start + maximumLength - 1var cursor = range.startvar data = Data()30 unmodified lines}func store(data: Data, start: Int64) {guard !data.isEmpty else {return}12 unmodified lines}func evictKeepingBytesNear(offset: Int64) {let lowerBound = max(0, offset - byteBudget)let removed = chunks.filter { $0.end < lowerBound }chunks.removeAll { $0.end < lowerBound }7 unmodified lines}func removeAll() {try? fileManager.removeItem(at: directory)chunks.removeAll()}48 unmodified linesprivate let prefetchLength: Int64private 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 = sessionself.fetchLength = fetchLengthprefetchLength = fetchLengthlet budget = byteBudget ?? max(30 * 1024 * 1024, (session.estimatedBitrate ?? 0) * 30 / 8)store = CachedRangeStore(sessionID: session.id, byteBudget: budget)}49 unmodified linesconnection.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 ?? fetchLengthif let lookup = store.lookup(range: range, maximumLength: maximumLength), lookup.isComplete {20 unmodified linesif responseStatus == 206 {store.store(data: response.data, start: range.start)store.evictKeepingBytesNear(offset: range.start)let contentType = response.headers["Content-Type"] as? Stringlet 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 {4 unmodified lines}}private func prefetch(after offset: Int64) {let range = HTTPRange(start: offset, end: offset + prefetchLength - 1)queue.async { [weak self] in8 unmodified lines}private func fetch(range: HTTPRange) -> UpstreamResponse? {let semaphore = DispatchSemaphore(value: 0)var result: UpstreamResponse?URLSession.shared.dataTask(with: upstreamRequest(for: range)) { data, response, _ inif let http = response as? HTTPURLResponse, let data {result = UpstreamResponse(statusCode: http.statusCode, headers: http.allHeaderFields, data: data)}semaphore.signal()}.resume()21 unmodified lines"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)11 unmodified linesconnection.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 nil4 unmodified lines}private struct HTTPRequest {let headers: [String: String]init?(data: Data) {1 unmodified linelet 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}1 unmodified linelet value = line[line.index(after: separator)...].trimmingCharacters(in: .whitespacesAndNewlines)headers[key] = value}self.headers = headers}}66 unmodified lines676869707172736 unmodified lines808182838485868730 unmodified lines11811912012112212312412512 unmodified lines1381391401411421431441457 unmodified lines15315415515615715815916048 unmodified lines20921021121221321421521621721821922049 unmodified lines27027127227327427527627727827928028128228328428528628728828929029129229329429520 unmodified lines3163173183193203213223234 unmodified lines3283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573588 unmodified lines36736836937037137237337437537637737837938038138238338438538638721 unmodified lines40941041141241341441511 unmodified lines4274284294304314324334344354364374384 unmodified lines4434444454464474484494501 unmodified line4524534544554564574584594604614624634644654664671 unmodified line46947047147247347447547666 unmodified linesprivate let directory: URLprivate let byteBudget: Int64private let fileManager: FileManagerprivate let lock = NSLock()private var chunks: [Chunk] = []init(sessionID: String, byteBudget: Int64, fileManager: FileManager = .default) {6 unmodified lines}func lookup(range: HTTPRange, maximumLength: Int64) -> Lookup? {lock.lock()defer { lock.unlock() }let requestedEnd = range.end ?? range.start + maximumLength - 1var cursor = range.startvar data = Data()30 unmodified lines}func store(data: Data, start: Int64) {lock.lock()defer { lock.unlock() }guard !data.isEmpty else {return}12 unmodified lines}func evictKeepingBytesNear(offset: Int64) {lock.lock()defer { lock.unlock() }let lowerBound = max(0, offset - byteBudget)let removed = chunks.filter { $0.end < lowerBound }chunks.removeAll { $0.end < lowerBound }7 unmodified lines}func removeAll() {lock.lock()defer { lock.unlock() }try? fileManager.removeItem(at: directory)chunks.removeAll()}48 unmodified linesprivate let prefetchLength: Int64private var listener: NWListener?private let queue = DispatchQueue(label: "dreamio.native-stream-cache-proxy")private let workQueue = DispatchQueue(label: "dreamio.native-stream-cache-proxy.work", attributes: .concurrent)init(session: Session, byteBudget: Int64? = nil, fetchLength: Int64 = 1024 * 1024) {self.session = sessionself.fetchLength = fetchLengthprefetchLength = 4 * fetchLengthlet budget = byteBudget ?? max(30 * 1024 * 1024, (session.estimatedBitrate ?? 0) * 30 / 8)store = CachedRangeStore(sessionID: session.id, byteBudget: budget)}49 unmodified linesconnection.cancel()return}self.workQueue.async {self.respond(to: request, on: connection)}}}private func respond(to request: HTTPRequest, on connection: NWConnection) {guard request.path.hasPrefix("/stream/") else {sendStatus(404, on: connection)return}if request.method == "HEAD" {respondToHead(on: connection)return}guard request.method == "GET" else {sendStatus(405, on: connection)return}let requestedRange = HTTPRange.parse(request.headers["range"])let range = requestedRange ?? HTTPRange(start: 0, end: fetchLength - 1)let maximumLength = range.length ?? fetchLengthif let lookup = store.lookup(range: range, maximumLength: maximumLength), lookup.isComplete {20 unmodified linesif responseStatus == 206 {store.store(data: response.data, start: range.start)store.evictKeepingBytesNear(offset: range.start)let contentType = headerValue(response.headers, named: "Content-Type")let totalLength = totalLength(from: headerValue(response.headers, named: "Content-Range"))send(data: response.data, statusCode: 206, rangeStart: range.start, totalLength: totalLength, contentType: contentType, on: connection)prefetch(after: range.start + Int64(response.data.count))} else {4 unmodified lines}}private func respondToHead(on connection: NWConnection) {guard let response = fetchHead() ?? fetch(range: HTTPRange(start: 0, end: 0)) else {sendStatus(502, on: connection)return}var headers = ["Accept-Ranges": response.statusCode == 206 ? "bytes" : headerValue(response.headers, named: "Accept-Ranges") ?? "bytes","Connection": "close"]if let contentType = headerValue(response.headers, named: "Content-Type") {headers["Content-Type"] = contentType}if let length = headerValue(response.headers, named: "Content-Length") {headers["Content-Length"] = length} else if let total = totalLength(from: headerValue(response.headers, named: "Content-Range")) {headers["Content-Length"] = "\(total)"} else {headers["Content-Length"] = "0"}#if DEBUGprint("[DreamioStreamProxy] head status=\(response.statusCode) length=\(headers["Content-Length"] ?? "unknown")")#endifsend(statusCode: 200, headers: headers, body: Data(), on: connection)}private func prefetch(after offset: Int64) {let range = HTTPRange(start: offset, end: offset + prefetchLength - 1)queue.async { [weak self] in8 unmodified lines}private func fetch(range: HTTPRange) -> UpstreamResponse? {fetch(request: upstreamRequest(for: range))}private func fetchHead() -> UpstreamResponse? {var request = upstreamRequest(for: nil)request.httpMethod = "HEAD"return fetch(request: request)}private func fetch(request: URLRequest) -> UpstreamResponse? {let semaphore = DispatchSemaphore(value: 0)var result: UpstreamResponse?URLSession.shared.dataTask(with: request) { data, response, _ inif let http = response as? HTTPURLResponse {result = UpstreamResponse(statusCode: http.statusCode, headers: http.allHeaderFields, data: data ?? Data())}semaphore.signal()}.resume()21 unmodified lines"Accept-Ranges": "none","Connection": "close"]if let contentType = headerValue(upstreamHeaders, named: "Content-Type") {headers["Content-Type"] = contentType}send(statusCode: statusCode, headers: headers, body: data, on: connection)11 unmodified linesconnection.send(content: response, completion: .contentProcessed { _ in connection.cancel() })}private func headerValue(_ headers: [AnyHashable: Any], named name: String) -> String? {headers.first { key, _ inString(describing: key).caseInsensitiveCompare(name) == .orderedSame}?.value as? String}private func totalLength(from contentRange: String?) -> Int64? {guard let contentRange, let slash = contentRange.lastIndex(of: "/") else {return nil4 unmodified lines}private struct HTTPRequest {let method: Stringlet path: Stringlet headers: [String: String]init?(data: Data) {1 unmodified linelet headerBlock = string.components(separatedBy: "\r\n\r\n").first else {return nil}let lines = headerBlock.components(separatedBy: "\r\n")guard let requestLine = lines.first else {return nil}let parts = requestLine.split(separator: " ")guard parts.count >= 2 else {return nil}var headers: [String: String] = [:]for line in lines.dropFirst() {guard let separator = line.firstIndex(of: ":") else {continue}1 unmodified linelet value = line[line.index(after: separator)...].trimmingCharacters(in: .whitespacesAndNewlines)headers[key] = value}method = String(parts[0]).uppercased()path = String(parts[1])self.headers = headers}}
Related issues or PRs
Follow-up Beads issue: dreamio-6bv.