Dreamio turn documentation · May 26, 2026

# Fix VLC Range Cache for MKV Streams

Removed the blanket Matroska/WebM cache bypass so direct-file MKV streams can use Dreamio's local range cache when the origin server confirms byte-range support.

## Summary

Dreamio was refusing to range-cache MKV streams before checking the server. That made Torrentio and Real-Debrid MKV playback open in direct mode, so seek prefetch could not run. The cache probe now lets normal HTTP range capability decide whether the local cache should be used.

## Changes Made

- Removed the hard-coded cache bypass for `.mkv`, `.mk3d`, `.mka`, `.mks`, and `.webm` URLs.
- Kept the existing non-HTTP and HLS playlist fallbacks intact.
- Updated the range probe regression test so MKV URLs are cacheable when the server returns `Accept-Ranges: bytes` and a valid `Content-Length`.

## Context

The diagnostic logs showed `[DreamioVLC] cache fallback reason=tail-index-container`, followed by `opening mode=direct` and direct-mode seek logs. That fallback came from an extension check, not a failed HTTP range probe. Because many debrid MKV streams do support byte ranges, the app was leaving useful buffering behavior on the table.

## Important Implementation Details

- The probe still requires either a HEAD response with byte-range support and content length, or a successful `GET Range: bytes=0-0` response.
- If an MKV origin ignores range requests, Dreamio still falls back to direct playback through the existing `range-probe-status-...` path.
- The expected debug signal for a compatible MKV is now `[DreamioVLC] opening mode=local-cache`, and seeks should include `byteOffset=...`.

## Relevant Diff Snippets

### Dreamio/ProgressiveHTTPRangeCache.swift

Dreamio/ProgressiveHTTPRangeCache.swift
-8

    271 unmodified lines27227327427527627727827928028152 unmodified lines334335336337338339340341342343271 unmodified lines        guard !url.path.lowercased().hasSuffix(".m3u8") else {            return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "hls-playlist")        }        guard !Self.shouldBypassCache(for: url) else {            return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "tail-index-container")        }
            if let head = try? await response(for: request(method: "HEAD")),           (200..<400).contains(head.statusCode) {            let acceptsRanges = header("Accept-Ranges", in: head)?.lowercased().contains("bytes") == true52 unmodified lines        response.value(forHTTPHeaderField: name)    }
        private static func shouldBypassCache(for url: URL) -> Bool {        let extensionName = url.pathExtension.lowercased()        return ["mkv", "mk3d", "mka", "mks", "webm"].contains(extensionName)    }}
    enum HTTPRangeCacheError: Error {271 unmodified lines27227327427527627752 unmodified lines330331332333334335271 unmodified lines        guard !url.path.lowercased().hasSuffix(".m3u8") else {            return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "hls-playlist")        }        if let head = try? await response(for: request(method: "HEAD")),           (200..<400).contains(head.statusCode) {            let acceptsRanges = header("Accept-Ranges", in: head)?.lowercased().contains("bytes") == true52 unmodified lines        response.value(forHTTPHeaderField: name)    }
    }
    enum HTTPRangeCacheError: Error {

### Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
-8+13

    42 unmodified lines43444546474849491 unmodified lines5415425435445455465475485495505515525535545555565573 unmodified lines56156256356456556656756856942 unmodified lines        testRangeCacheForegroundMissFetchesAlignedChunks()        await testRangeCacheForegroundMissReprioritizesPrefetch()        await testRangeCacheHitFollowsActualPostSeekReadArea()        await testRangeProbeBypassesTailIndexContainers()        await testRangeProbeFallsBackWhenServerIgnoresRange()        await testRangeFetcherPreservesHeaders()        print("StreamResolverTests passed")491 unmodified lines        try? await Task.sleep(nanoseconds: 50_000_000)    }
        private static func testRangeProbeBypassesTailIndexContainers() async {        var requestCount = 0        MockURLProtocol.handler = { request in            requestCount += 1            let response = HTTPURLResponse(                url: request.url!,                statusCode: 206,                httpVersion: nil,                headerFields: ["Content-Range": "bytes 0-0/20"]            )!            return (Data([1]), response)        }
            let fetcher = HTTPRangeRemoteFetcher(3 unmodified lines        )        let probe = await fetcher.probe()
            assertEqual(probe.isCacheable, false)        assertEqual(probe.fallbackReason, "tail-index-container")        assertEqual(requestCount, 0)        MockURLProtocol.handler = nil    }
    42 unmodified lines43444546474849491 unmodified lines5415425435445455465475485495505515525535545555565575585595605613 unmodified lines56556656756856957057157257357442 unmodified lines        testRangeCacheForegroundMissFetchesAlignedChunks()        await testRangeCacheForegroundMissReprioritizesPrefetch()        await testRangeCacheHitFollowsActualPostSeekReadArea()        await testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges()        await testRangeProbeFallsBackWhenServerIgnoresRange()        await testRangeFetcherPreservesHeaders()        print("StreamResolverTests passed")491 unmodified lines        try? await Task.sleep(nanoseconds: 50_000_000)    }
        private static func testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges() async {        var requestCount = 0        MockURLProtocol.handler = { request in            requestCount += 1            assertEqual(request.httpMethod, "HEAD")            let response = HTTPURLResponse(                url: request.url!,                statusCode: 200,                httpVersion: nil,                headerFields: [                    "Accept-Ranges": "bytes",                    "Content-Length": "20"                ]            )!            return (Data(), response)        }
            let fetcher = HTTPRangeRemoteFetcher(3 unmodified lines        )        let probe = await fetcher.probe()
            assertEqual(probe.isCacheable, true)        assertEqual(probe.contentLength, 20)        assertEqual(probe.fallbackReason, nil)        assertEqual(requestCount, 1)        MockURLProtocol.handler = nil    }

## Expected Impact for End-Users

Compatible MKV direct-file streams should start through Dreamio's local range cache instead of direct VLC mode. Backward and forward skips can now prime nearby bytes, which should reduce stalls after seeking on supported servers.

## Validation

- Passed `swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/ProgressiveHTTPRangeCache.swift Dreamio/ExternalSubtitleTrackParser.swift Tests/StreamResolverTests.swift -o /tmp/StreamResolverTests && /tmp/StreamResolverTests`
- Passed `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build`

## Issues, Limitations, and Mitigations

This does not guarantee every MKV will use the cache. Servers that lack byte-range support, omit content length, reject range requests, or fail the local cache server setup still fall back to direct VLC playback. That is intentional so playback continues instead of failing hard.

## Follow-up Work

- Test the original Torrentio/Real-Debrid South Park stream on device and confirm logs show `opening mode=local-cache`.
- If startup is slower on some MKV sources, consider measuring HEAD latency and falling back to the tiny range probe sooner.
- Improve external subtitle auto-selection so English does not lose to the first parsed subtitle track.

## New Changes as of May 26, 2026 at 8:16 AM

### Summary of changes

Added a short timeout to the range-cache probe path so a slow HEAD or tiny range request cannot prevent native playback from starting.

### Why this change was made

Device logs showed the MKV stream reached `[DreamioVLC] cache-probe` but never logged either `opening mode=local-cache` or `opening mode=direct` before the native-player startup timeout. The cache probe was waiting too long before any VLC media was opened.

### Code diffs

### Dreamio/ProgressiveHTTPRangeCache.swift

Dreamio/ProgressiveHTTPRangeCache.swift
-4+7

    264 unmodified lines2652662672682692702712722732742752762772782 unmodified lines28128228328428528628729 unmodified lines317318319320321322323324325264 unmodified lines        self.session = session    }
        func probe() async -> HTTPRangeProbeResult {        guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {            return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "non-http-url")        }        guard !url.path.lowercased().hasSuffix(".m3u8") else {            return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "hls-playlist")        }        if let head = try? await response(for: request(method: "HEAD")),           (200..<400).contains(head.statusCode) {            let acceptsRanges = header("Accept-Ranges", in: head)?.lowercased().contains("bytes") == true            let length = header("Content-Length", in: head).flatMap(Int64.init)2 unmodified lines            }        }
            var tinyRequest = request(method: "GET")        tinyRequest.setValue("bytes=0-0", forHTTPHeaderField: "Range")        do {            let (data, response) = try await session.data(for: tinyRequest)29 unmodified lines        return response as? HTTPURLResponse    }
        private func request(method: String) -> URLRequest {        var request = URLRequest(url: url)        request.httpMethod = method        headers.forEach { key, value in            request.setValue(value, forHTTPHeaderField: key)        }264 unmodified lines2652662672682692702712722732742752762772782 unmodified lines28128228328428528628729 unmodified lines317318319320321322323324325326327328264 unmodified lines        self.session = session    }
        func probe(timeoutInterval: TimeInterval = 3) async -> HTTPRangeProbeResult {        guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {            return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "non-http-url")        }        guard !url.path.lowercased().hasSuffix(".m3u8") else {            return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "hls-playlist")        }        if let head = try? await response(for: request(method: "HEAD", timeoutInterval: timeoutInterval)),           (200..<400).contains(head.statusCode) {            let acceptsRanges = header("Accept-Ranges", in: head)?.lowercased().contains("bytes") == true            let length = header("Content-Length", in: head).flatMap(Int64.init)2 unmodified lines            }        }
            var tinyRequest = request(method: "GET", timeoutInterval: timeoutInterval)        tinyRequest.setValue("bytes=0-0", forHTTPHeaderField: "Range")        do {            let (data, response) = try await session.data(for: tinyRequest)29 unmodified lines        return response as? HTTPURLResponse    }
        private func request(method: String, timeoutInterval: TimeInterval? = nil) -> URLRequest {        var request = URLRequest(url: url)        request.httpMethod = method        if let timeoutInterval {            request.timeoutInterval = timeoutInterval        }        headers.forEach { key, value in            request.setValue(value, forHTTPHeaderField: key)        }

### Dreamio/VLCNativePlaybackBackend.swift

Dreamio/VLCNativePlaybackBackend.swift
-1+1

    77 unmodified lines7879808182838477 unmodified lines                return            }            let fetcher = HTTPRangeRemoteFetcher(url: request.playbackURL, headers: request.headers)            let probe = await fetcher.probe()            guard !Task.isCancelled else {                return            }77 unmodified lines7879808182838477 unmodified lines                return            }            let fetcher = HTTPRangeRemoteFetcher(url: request.playbackURL, headers: request.headers)            let probe = await fetcher.probe(timeoutInterval: 1.5)            guard !Task.isCancelled else {                return            }

### Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+27

    43 unmodified lines444546474849522 unmodified lines57257357457557657743 unmodified lines        await testRangeCacheForegroundMissReprioritizesPrefetch()        await testRangeCacheHitFollowsActualPostSeekReadArea()        await testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges()        await testRangeProbeFallsBackWhenServerIgnoresRange()        await testRangeFetcherPreservesHeaders()        print("StreamResolverTests passed")522 unmodified lines        MockURLProtocol.handler = nil    }
        private static func byteRange(fromHeader header: String, contentLength: Int64) -> HTTPByteRange {        let value = header.replacingOccurrences(of: "bytes=", with: "")        let pieces = value.split(separator: "-", maxSplits: 1).map(String.init)43 unmodified lines44454647484950522 unmodified lines57357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360443 unmodified lines        await testRangeCacheForegroundMissReprioritizesPrefetch()        await testRangeCacheHitFollowsActualPostSeekReadArea()        await testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges()        await testRangeProbeAppliesRequestTimeout()        await testRangeProbeFallsBackWhenServerIgnoresRange()        await testRangeFetcherPreservesHeaders()        print("StreamResolverTests passed")522 unmodified lines        MockURLProtocol.handler = nil    }
        private static func testRangeProbeAppliesRequestTimeout() async {        MockURLProtocol.handler = { request in            assertEqual(request.timeoutInterval, 1.5)            let response = HTTPURLResponse(                url: request.url!,                statusCode: 200,                httpVersion: nil,                headerFields: [                    "Accept-Ranges": "bytes",                    "Content-Length": "20"                ]            )!            return (Data(), response)        }
            let fetcher = HTTPRangeRemoteFetcher(            url: URL(string: "https://cdn.example.test/show.mkv")!,            headers: [:],            session: mockSession()        )        let probe = await fetcher.probe(timeoutInterval: 1.5)
            assertEqual(probe.isCacheable, true)        MockURLProtocol.handler = nil    }
        private static func byteRange(fromHeader header: String, contentLength: Int64) -> HTTPByteRange {        let value = header.replacingOccurrences(of: "bytes=", with: "")        let pieces = value.split(separator: "-", maxSplits: 1).map(String.init)

### Related issues or PRs

Beads issue: `dreamio-btc`. This follows the earlier `dreamio-3sw` MKV cache enablement work.

### Validation

- Passed `swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/ProgressiveHTTPRangeCache.swift Dreamio/ExternalSubtitleTrackParser.swift Tests/StreamResolverTests.swift -o /tmp/StreamResolverTests && /tmp/StreamResolverTests`
- Passed `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build`