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.