# VLC Seeking With a Local Range Cache

Dreamio now probes file-like HTTP streams for byte-range support and, when safe, feeds MobileVLCKit through a loopback URL backed by a sparse progressive cache.

## Summary

Implemented a Dreamio-owned progressive HTTP range cache for native VLC playback. Cacheable HTTP/HTTPS streams are served to VLC from `127.0.0.1`, while HLS, live, non-HTTP, unknown-length, and non-range sources stay on direct MobileVLCKit playback.

## Changes Made

- Added `ProgressiveHTTPRangeCache.swift` with range parsing, sparse cached byte storage, remote range fetching, prefetch window logic, and a small loopback HTTP server.
- Updated `VLCNativePlaybackBackend` to probe before playback, choose local-cache vs direct mode, apply separate VLC caching options, and reprioritize prefetching on seek/jump.
- Preserved upstream request headers in remote range fetches, including user agent, referrer, cookies, and custom auth headers.
- Added diagnostics for cache mode, probe fallback reasons, seek byte estimates, cache hits/misses, fetched ranges, and throttled repeated buffering logs.
- Added range/cache unit coverage and URLProtocol-backed fetcher tests.

## Context

MobileVLCKit exposes coarse input caching knobs, but not a precise “keep nearby bytes around the playhead” buffer. This change puts Dreamio in charge of the byte window for regular file-like streams and leaves segment/playlist media on VLC’s normal path.

## Important Implementation Details

- The cache uses sparse byte ranges and merges overlapping or adjacent segments rather than downloading whole files.
- Probe logic uses `HEAD` first, then a tiny `Range: bytes=0-0` request if needed.
- The local server responds to VLC range requests with `206 Partial Content` and fills misses from the upstream URL.
- Prefetch targets roughly 30 seconds behind and 60 seconds ahead when duration is known; otherwise it uses fixed byte heuristics.
- Direct fallback uses a larger `network-caching` value and `http-reconnect`; loopback playback uses lower caching because Dreamio is buffering locally.

## Relevant Diff Snippets

Rendered with `@pierre/diffs/ssr`. These are selected snippets, not the full patch, to keep the turn note readable.

### Dreamio/ProgressiveHTTPRangeCache.swift

Dreamio/ProgressiveHTTPRangeCache.swift
+25

    12345678910111213141516171819202122232425struct HTTPContentRange: Equatable {    let range: HTTPByteRange    let totalLength: Int64?
        static func parse(_ value: String) -> HTTPContentRange? {        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)        guard trimmed.lowercased().hasPrefix("bytes ") else {            return nil        }        ...    }}
    final class ProgressiveHTTPRangeCacheSession {    func data(for requestedRange: HTTPByteRange) async throws -> Data {        if let data = store.data(for: bounded) {            print("[DreamioRangeCache] cache=hit range=\(bounded.start)-\(bounded.end)")            return data        }        let data = try await fetcher.fetch(range: bounded)        store.insert(data: data, at: bounded.start)        prefetch(aroundByteOffset: bounded.end + 1)        return store.data(for: bounded) ?? data    }}

### Dreamio/VLCNativePlaybackBackend.swift

Dreamio/VLCNativePlaybackBackend.swift
-4+26

    51 unmodified lines48495034 unmodified lines9951 unmodified lines        let media = VLCMedia(url: request.playbackURL)        mediaPlayer.media = media        mediaPlayer.play()34 unmodified lines        mediaPlayer.position = max(0, min(1, position))51 unmodified lines5253545556575859606162636465666768697071727334 unmodified lines12412512612751 unmodified lines        playbackStartupTask = Task { [weak self] in            let fetcher = HTTPRangeRemoteFetcher(url: request.playbackURL, headers: request.headers)            let probe = await fetcher.probe()            if probe.isCacheable, let contentLength = probe.contentLength, contentLength > 0 {                let session = ProgressiveHTTPRangeCacheSession(                    fetcher: fetcher,                    contentLength: contentLength,                    durationProvider: { [weak self] in self?.duration ?? 0 }                )                let localURL = try ProgressiveHTTPRangeCacheServer.shared.localURL(for: session)                await MainActor.run {                    self?.rangeCacheSession = session                    session.prefetch(aroundByteOffset: 0)                    self?.startVLCMedia(url: localURL, request: request, playbackMode: "local-cache", cachingMilliseconds: 500, includeRemoteHTTPOptions: false)                }                return            }
                await MainActor.run {                self?.startVLCMedia(url: request.playbackURL, request: request, playbackMode: "direct", cachingMilliseconds: 2500, includeRemoteHTTPOptions: true)            }        }34 unmodified lines        let clamped = max(0, min(1, position))        rangeCacheSession?.prefetch(aroundByteOffset: rangeCacheSession?.byteOffset(for: clamped) ?? 0)        print("[DreamioVLC] seek targetPosition=\(clamped)")        mediaPlayer.position = clamped

### Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+15

    23 unmodified lines237 unmodified lines23 unmodified lines237 unmodified lines23 unmodified lines242526272829237 unmodified lines27327427527627727827928028123 unmodified lines        testContentRangeParsing()        testSparseRangeStoreMergesOverlaps()        testSparseRangeStoreHitPartialHitAndMiss()        testSparseRangeStoreEvictsOutsideWindow()        await testRangeProbeFallsBackWhenServerIgnoresRange()        await testRangeFetcherPreservesHeaders()237 unmodified lines    private static func testRangeFetcherPreservesHeaders() async {        MockURLProtocol.handler = { request in            assertEqual(request.value(forHTTPHeaderField: "User-Agent"), "DreamioTest/1")            assertEqual(request.value(forHTTPHeaderField: "Referer"), "https://web.stremio.com/")            assertEqual(request.value(forHTTPHeaderField: "Cookie"), "session=abc")            assertEqual(request.value(forHTTPHeaderField: "Range"), "bytes=5-7")            return (Data([5, 6, 7]), HTTPURLResponse(url: request.url!, statusCode: 206, httpVersion: nil, headerFields: nil)!)        }    }

### Dreamio.xcodeproj/project.pbxproj

Dreamio.xcodeproj/project.pbxproj
+3

    14 unmodified lines71 unmodified lines140 unmodified lines14 unmodified lines71 unmodified lines140 unmodified lines14 unmodified lines1571 unmodified lines93140 unmodified lines24014 unmodified lines        6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */; };71 unmodified lines                6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */,140 unmodified lines                6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */,

## Expected Impact for End-Users

Nearby seeks on cacheable MP4/MKV/AVI/WebM-style HTTP streams should recover faster because VLC reads from a local range-aware endpoint backed by targeted upstream fills. Unsupported sources should continue playing through the direct MobileVLCKit path.

## Validation

- `xcrun swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/ProgressiveHTTPRangeCache.swift Tests/StreamResolverTests.swift -o /tmp/StreamResolverTests && /tmp/StreamResolverTests` passed.
- `pod install` restored missing CocoaPods support files for the worktree.
- `xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS Simulator' build` passed.

## Issues, Limitations, and Mitigations

- Manual device validation against the original problematic stream is still needed because local playback quality depends on real server range behavior.
- The first version intentionally does not cache HLS/live streams; those remain on direct VLC playback because their model is playlist/segment based.
- The loopback server is intentionally small and per-process. If future playback needs multiple concurrent videos, session lifecycle cleanup should be tightened further.

## Follow-up Work

- Run the manual 15-second seek validation on device with the stream that produced the buffering logs.
- Add an end-to-end local server integration test that opens the loopback URL and verifies repeated range reuse through the full server path.
- Consider exposing cache counters in a debug overlay if native playback diagnostics continue to be a focus.

## New Changes as of 2026-05-25 18:32 EDT

### Summary of changes

Fixed the loopback cache server startup so Dreamio waits for Network.framework to report the real assigned port before giving VLC the local playback URL.

### Why this change was made

Device logs showed VLC opening `http://127.0.0.1:0/stream/...`. Port `0` is only the ephemeral-port request placeholder, not a usable listening port, so VLC immediately failed playback even though the upstream range cache had started fetching data.

### Code diffs

    ProgressiveHTTPRangeCacheServer.localURL(for:) is now async.
     let assignedPort = try await startIfNeeded()
     URL(string: "http://127.0.0.1:\(assignedPort)/stream/\(session.id)")

     listener.stateUpdateHandler waits for .ready
     port = UInt16(listener.port.rawValue)

     startup continuations resume only after the real port is available.

### Related issues or PRs

Related to Beads issue `dreamio-11s` and branch `lavender/vlc-local-range-cache`.

## New Changes as of 2026-05-25 19:43 EDT

### Summary of changes

Capped the VLC loopback range cache so an oversized player request like `bytes=0-711080521` is answered as a small partial response instead of being fetched and stored as one huge in-memory `Data` value.

### Why this change was made

The device logs showed VLC requesting almost the entire file from the local cache on startup. The cache honored that range literally, which could allocate hundreds of megabytes for the upstream response and then keep another copy in the sparse cache. That explains the memory kill during playback.

### Code diffs

Rendered with `@pierre/diffs/ssr`. These are focused snippets for the memory cap and its tests.

### Dreamio/ProgressiveHTTPRangeCache.swift and Tests/StreamResolverTests.swift

Dreamio/ProgressiveHTTPRangeCache.swift
-3+21

    114 unmodified lines115116117118119144 unmodified lines266267174 unmodified lines446447448449450451452453454114 unmodified lines    func evict(keeping window: HTTPByteRange) {        lock.withLock {            segments.removeAll { !$0.range.overlapsOrTouches(window) }        }    }144 unmodified lines        return store.data(for: bounded) ?? data    }174 unmodified lines        let requestedRange = parseRangeHeader(in: requestText, contentLength: session.contentLength)            ?? HTTPByteRange(start: 0, end: min(session.contentLength - 1, 1_048_575))        do {            let data = try await session.data(for: requestedRange)            let headers = [                "Accept-Ranges": "bytes",                "Content-Length": "\(data.count)",                "Content-Range": "bytes \(requestedRange.start)-\(requestedRange.end)/\(session.contentLength)",                "Content-Type": "application/octet-stream",114 unmodified lines115116117118119120121122123124125126127128144 unmodified lines278279280281282283284285286287174 unmodified lines466467468469470471472473474475114 unmodified lines    func evict(keeping window: HTTPByteRange) {        lock.withLock {            segments = segments.compactMap { segment in                guard segment.range.overlapsOrTouches(window) else {                    return nil                }                let start = max(segment.range.start, window.start)                let end = min(segment.range.end, window.end)                let lower = Int(start - segment.range.start)                let upper = Int(end - segment.range.start + 1)                return Segment(range: HTTPByteRange(start: start, end: end), data: segment.data.subdata(in: lower..<upper))            }        }    }144 unmodified lines        return store.data(for: bounded) ?? data    }
        func responseRange(for requestedRange: HTTPByteRange) -> HTTPByteRange {        let bounded = clamp(requestedRange)        return HTTPByteRange(            start: bounded.start,            end: min(bounded.end, bounded.start + responseChunkSize - 1)        )    }174 unmodified lines        let requestedRange = parseRangeHeader(in: requestText, contentLength: session.contentLength)            ?? HTTPByteRange(start: 0, end: min(session.contentLength - 1, 1_048_575))        let responseRange = session.responseRange(for: requestedRange)        do {            let data = try await session.data(for: responseRange)            let headers = [                "Accept-Ranges": "bytes",                "Content-Length": "\(data.count)",                "Content-Range": "bytes \(responseRange.start)-\(responseRange.end)/\(session.contentLength)",                "Content-Type": "application/octet-stream",

Tests/StreamResolverTests.swift
+12

    320 unmodified lines319320320 unmodified lines        assertEqual(store.cachedRanges, [HTTPByteRange(start: 10, end: 12)])    }320 unmodified lines321322323324325326327328329330331332333334320 unmodified lines        assertEqual(store.cachedRanges, [HTTPByteRange(start: 10, end: 12)])    }
        private static func testSparseRangeStoreTrimsOverlappingWindow() {        let store = SparseHTTPByteRangeStore()        store.insert(data: Data([0, 1, 2, 3, 4, 5]), at: 0)        store.evict(keeping: HTTPByteRange(start: 2, end: 4))        assertEqual(store.cachedRanges, [HTTPByteRange(start: 2, end: 4)])    }
        private static func testRangeCacheSessionCapsResponseRange() {        let session = ProgressiveHTTPRangeCacheSession(fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]), contentLength: 711_080_522, durationProvider: { 0 })        assertEqual(session.responseRange(for: HTTPByteRange(start: 0, end: 711_080_521)), HTTPByteRange(start: 0, end: 1_048_575))    }

### Related issues or PRs

Related to Beads issue `dreamio-9gw` and branch `lavender/vlc-local-range-cache`.

## New Changes as of 2026-05-25 23:59 EDT

### Summary of changes

Adjusted range-cache prefetching for seek-heavy playback so the cache prioritizes chunks around the post-seek byte offset and avoids cancelling useful in-flight prefetch work when the requested offset remains inside the active window.

### Why this change was made

Runtime logs showed VLC entering buffering after a seek while Dreamio repeatedly reported cache misses and cancelled prefetches. The prefetcher was warming from the back edge of a broad window, which could spend bandwidth behind the seek target before fetching the bytes VLC needed next.

### Code diffs

Rendered with `@pierre/diffs/ssr`. The diffs below cover the seek-prefetch changes and focused regression coverage.

### Dreamio/ProgressiveHTTPRangeCache.swift

Dreamio/ProgressiveHTTPRangeCache.swift
-4+41

    15 unmodified lines161718192021232 unmodified lines25425525625725825928 unmodified lines2882892902912922932942952962971 unmodified line2993003013023033043053063072 unmodified lines3103113123133143153163173183193203213223239 unmodified lines33333433533633733815 unmodified lines    func merged(with other: HTTPByteRange) -> HTTPByteRange {        HTTPByteRange(start: min(start, other.start), end: max(end, other.end))    }}
    struct HTTPContentRange: Equatable {232 unmodified lines    private let prefetchChunkSize: Int64 = 1_048_576    private let responseChunkSize: Int64 = 1_048_576    private var prefetchTask: Task<Void, Never>?
        init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {        self.fetcher = fetcher28 unmodified lines    }
        func prefetch(aroundByteOffset offset: Int64) {        prefetchTask?.cancel()        let window = targetWindow(aroundByteOffset: offset)        store.evict(keeping: window)        guard !store.hasData(for: window) else {            return        }
    1 unmodified line            guard let self else {                return            }            var cursor = window.start            while cursor <= window.end, !Task.isCancelled {                let chunk = HTTPByteRange(start: cursor, end: min(window.end, cursor + prefetchChunkSize - 1))                if !store.hasData(for: chunk) {                    do {                        let data = try await fetcher.fetch(range: chunk)2 unmodified lines                        print("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)")#endif                    } catch {#if DEBUG                        print("[DreamioRangeCache] prefetch failed range=\(chunk.start)-\(chunk.end) error=\(error)")#endif                        return                    }                }                cursor = chunk.end + 1            }        }    }
    9 unmodified lines        return clamp(HTTPByteRange(start: offset - behind, end: offset + ahead))    }
        private func estimatedBytesPerSecond() -> Int64 {        let duration = durationProvider()        guard duration > 1 else {15 unmodified lines16171819202122232425232 unmodified lines25825926026126226326428 unmodified lines2932942952962972982993003013023033043053063073081 unmodified line3103113123133143153163173183192 unmodified lines3223233243253263273283293303313323333343353363373389 unmodified lines34834935035135235335435535635735835936036136236336436536636736836937037137237337437515 unmodified lines    func merged(with other: HTTPByteRange) -> HTTPByteRange {        HTTPByteRange(start: min(start, other.start), end: max(end, other.end))    }
        func contains(_ offset: Int64) -> Bool {        start <= offset && offset <= end    }}
    struct HTTPContentRange: Equatable {232 unmodified lines    private let prefetchChunkSize: Int64 = 1_048_576    private let responseChunkSize: Int64 = 1_048_576    private var prefetchTask: Task<Void, Never>?    private var activePrefetchWindow: HTTPByteRange?
        init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {        self.fetcher = fetcher28 unmodified lines    }
        func prefetch(aroundByteOffset offset: Int64) {        if activePrefetchWindow?.contains(offset) == true, prefetchTask?.isCancelled == false {            return        }
            prefetchTask?.cancel()        let window = targetWindow(aroundByteOffset: offset)        activePrefetchWindow = window        store.evict(keeping: window)        guard !store.hasData(for: window) else {            activePrefetchWindow = nil            return        }
    1 unmodified line            guard let self else {                return            }            for chunk in self.prefetchChunks(in: window, preferredOffset: offset) {                guard !Task.isCancelled else {                    return                }                if !store.hasData(for: chunk) {                    do {                        let data = try await fetcher.fetch(range: chunk)2 unmodified lines                        print("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)")#endif                    } catch {                        if Task.isCancelled {                            return                        }#if DEBUG                        print("[DreamioRangeCache] prefetch failed range=\(chunk.start)-\(chunk.end) error=\(error)")#endif                        return                    }                }            }            self.activePrefetchWindow = nil        }    }
    9 unmodified lines        return clamp(HTTPByteRange(start: offset - behind, end: offset + ahead))    }
        func prefetchChunks(in window: HTTPByteRange, preferredOffset offset: Int64) -> [HTTPByteRange] {        let boundedOffset = max(window.start, min(window.end, offset))        let preferredStart = window.start + ((boundedOffset - window.start) / prefetchChunkSize) * prefetchChunkSize        var chunks: [HTTPByteRange] = []
            var cursor = preferredStart        while cursor <= window.end {            let chunk = HTTPByteRange(start: cursor, end: min(window.end, cursor + prefetchChunkSize - 1))            chunks.append(chunk)            cursor = chunk.end + 1        }
            cursor = window.start        while cursor < preferredStart {            let chunk = HTTPByteRange(start: cursor, end: min(preferredStart - 1, cursor + prefetchChunkSize - 1))            chunks.append(chunk)            cursor = chunk.end + 1        }
            return chunks    }
        private func estimatedBytesPerSecond() -> Int64 {        let duration = durationProvider()        guard duration > 1 else {

### Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+23

    35 unmodified lines363738394041302 unmodified lines34434534634734834935 unmodified lines        testSparseRangeStoreEvictsOutsideWindow()        testSparseRangeStoreTrimsOverlappingWindow()        testRangeCacheSessionCapsResponseRange()        await testRangeProbeFallsBackWhenServerIgnoresRange()        await testRangeFetcherPreservesHeaders()        print("StreamResolverTests passed")302 unmodified lines        assertEqual(responseRange, HTTPByteRange(start: 0, end: 1_048_575))    }
        private static func testRangeProbeFallsBackWhenServerIgnoresRange() async {        MockURLProtocol.handler = { request in            if request.httpMethod == "HEAD" {35 unmodified lines36373839404142302 unmodified lines34534634734834935035135235335435535635735835936036136236336436536636736836937037137235 unmodified lines        testSparseRangeStoreEvictsOutsideWindow()        testSparseRangeStoreTrimsOverlappingWindow()        testRangeCacheSessionCapsResponseRange()        testRangeCachePrefetchPrioritizesSeekOffset()        await testRangeProbeFallsBackWhenServerIgnoresRange()        await testRangeFetcherPreservesHeaders()        print("StreamResolverTests passed")302 unmodified lines        assertEqual(responseRange, HTTPByteRange(start: 0, end: 1_048_575))    }
        private static func testRangeCachePrefetchPrioritizesSeekOffset() {        let session = ProgressiveHTTPRangeCacheSession(            fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),            contentLength: 20_000_000,            durationProvider: { 0 }        )
            let chunks = session.prefetchChunks(            in: HTTPByteRange(start: 0, end: 4_194_303),            preferredOffset: 2_200_000        )
            assertEqual(chunks.prefix(2).map { $0 }, [            HTTPByteRange(start: 2_097_152, end: 3_145_727),            HTTPByteRange(start: 3_145_728, end: 4_194_303)        ])        assertEqual(chunks.suffix(2).map { $0 }, [            HTTPByteRange(start: 0, end: 1_048_575),            HTTPByteRange(start: 1_048_576, end: 2_097_151)        ])    }
        private static func testRangeProbeFallsBackWhenServerIgnoresRange() async {        MockURLProtocol.handler = { request in            if request.httpMethod == "HEAD" {

### Related issues or PRs

Related to Beads issue `dreamio-42s` and branch `lavender/vlc-local-range-cache`.

## New Changes as of 2026-05-26 00:14 EDT

### Summary of changes

Changed foreground cache misses from VLC into a stronger prefetch signal. When VLC asks for a real range that is far from the current prefetch cursor, Dreamio now cancels the stale speculative task and restarts prefetching beside VLC's actual requested bytes.

### Why this change was made

The jump logs showed duration-based seek estimates could land around `28M` while VLC immediately requested ranges around `52M`. Because those real ranges were still inside the broad prefetch window, the cache previously kept fetching old chunks and left VLC buffering on repeated misses.

### Code diffs

Rendered with `@pierre/diffs/ssr`. These diffs cover foreground miss reprioritization and the regression test that reproduces the observed jump pattern.

### Dreamio/ProgressiveHTTPRangeCache.swift

Dreamio/ProgressiveHTTPRangeCache.swift
-2+33

    258 unmodified lines2592602612622632641 unmodified line2662672682692702716 unmodified lines2782792802812822832842852866 unmodified lines29329429529629729829930030130230330430530630730824 unmodified lines333334335336337338339340341342343344345346258 unmodified lines    private let responseChunkSize: Int64 = 1_048_576    private var prefetchTask: Task<Void, Never>?    private var activePrefetchWindow: HTTPByteRange?
        init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {        self.fetcher = fetcher1 unmodified line        self.durationProvider = durationProvider    }
        func data(for requestedRange: HTTPByteRange) async throws -> Data {        let bounded = clamp(requestedRange)        if let data = store.data(for: bounded) {6 unmodified lines#if DEBUG        print("[DreamioRangeCache] cache=miss range=\(bounded.start)-\(bounded.end)")#endif        let data = try await fetcher.fetch(range: bounded)        store.insert(data: data, at: bounded.start)        prefetch(aroundByteOffset: bounded.end + 1)        return store.data(for: bounded) ?? data    }
    6 unmodified lines    }
        func prefetch(aroundByteOffset offset: Int64) {        if activePrefetchWindow?.contains(offset) == true, prefetchTask?.isCancelled == false {            return        }
            prefetchTask?.cancel()        let window = targetWindow(aroundByteOffset: offset)        activePrefetchWindow = window        store.evict(keeping: window)        guard !store.hasData(for: window) else {            activePrefetchWindow = nil            return        }
    24 unmodified lines                }            }            self.activePrefetchWindow = nil        }    }
        func byteOffset(for position: Float) -> Int64 {        let clamped = max(0, min(1, position))        return Int64(Float(contentLength) * clamped)    }
        private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {        let bytesPerSecond = estimatedBytesPerSecond()        let behind = max(prefetchChunkSize, bytesPerSecond * 30)258 unmodified lines2592602612622632642651 unmodified line2672682692702712722732742752766 unmodified lines2832842852862872882892902912926 unmodified lines29930030130230330430530630730830931031131231331431531631731831932024 unmodified lines345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377258 unmodified lines    private let responseChunkSize: Int64 = 1_048_576    private var prefetchTask: Task<Void, Never>?    private var activePrefetchWindow: HTTPByteRange?    private var activePrefetchPreferredOffset: Int64?
        init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {        self.fetcher = fetcher1 unmodified line        self.durationProvider = durationProvider    }
        deinit {        cancelPrefetch()    }
        func data(for requestedRange: HTTPByteRange) async throws -> Data {        let bounded = clamp(requestedRange)        if let data = store.data(for: bounded) {6 unmodified lines#if DEBUG        print("[DreamioRangeCache] cache=miss range=\(bounded.start)-\(bounded.end)")#endif        cancelPrefetchIfNeeded(forForegroundRange: bounded)        let data = try await fetcher.fetch(range: bounded)        store.insert(data: data, at: bounded.start)        prefetch(aroundByteOffset: bounded.end + 1, forceRestart: true)        return store.data(for: bounded) ?? data    }
    6 unmodified lines    }
        func prefetch(aroundByteOffset offset: Int64) {        prefetch(aroundByteOffset: offset, forceRestart: false)    }
        func prefetch(aroundByteOffset offset: Int64, forceRestart: Bool) {        if !forceRestart, activePrefetchWindow?.contains(offset) == true, prefetchTask?.isCancelled == false {            return        }
            prefetchTask?.cancel()        let window = targetWindow(aroundByteOffset: offset)        activePrefetchWindow = window        activePrefetchPreferredOffset = offset        store.evict(keeping: window)        guard !store.hasData(for: window) else {            activePrefetchWindow = nil            activePrefetchPreferredOffset = nil            return        }
    24 unmodified lines                }            }            self.activePrefetchWindow = nil            self.activePrefetchPreferredOffset = nil        }    }
        func cancelPrefetch() {        prefetchTask?.cancel()        activePrefetchWindow = nil        activePrefetchPreferredOffset = nil    }
        func byteOffset(for position: Float) -> Int64 {        let clamped = max(0, min(1, position))        return Int64(Float(contentLength) * clamped)    }
        private func cancelPrefetchIfNeeded(forForegroundRange range: HTTPByteRange) {        guard activePrefetchWindow?.contains(range.start) == true,              let preferredOffset = activePrefetchPreferredOffset,              abs(range.start - preferredOffset) >= responseChunkSize else {            return        }#if DEBUG        print("[DreamioRangeCache] prefetch reprioritize from=\(preferredOffset) to=\(range.start)")#endif        cancelPrefetch()    }
        private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {        let bytesPerSecond = estimatedBytesPerSecond()        let behind = max(prefetchChunkSize, bytesPerSecond * 30)

### Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+57

    36 unmodified lines373839404142324 unmodified lines36736836937037137236 unmodified lines        testSparseRangeStoreTrimsOverlappingWindow()        testRangeCacheSessionCapsResponseRange()        testRangeCachePrefetchPrioritizesSeekOffset()        await testRangeProbeFallsBackWhenServerIgnoresRange()        await testRangeFetcherPreservesHeaders()        print("StreamResolverTests passed")324 unmodified lines        ])    }
        private static func testRangeProbeFallsBackWhenServerIgnoresRange() async {        MockURLProtocol.handler = { request in            if request.httpMethod == "HEAD" {36 unmodified lines37383940414243324 unmodified lines36836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842936 unmodified lines        testSparseRangeStoreTrimsOverlappingWindow()        testRangeCacheSessionCapsResponseRange()        testRangeCachePrefetchPrioritizesSeekOffset()        await testRangeCacheForegroundMissReprioritizesPrefetch()        await testRangeProbeFallsBackWhenServerIgnoresRange()        await testRangeFetcherPreservesHeaders()        print("StreamResolverTests passed")324 unmodified lines        ])    }
        private static func testRangeCacheForegroundMissReprioritizesPrefetch() async {        let queue = DispatchQueue(label: "dreamio.range-cache-test")        var requestedRanges: [String] = []        MockURLProtocol.handler = { request in            let range = request.value(forHTTPHeaderField: "Range") ?? ""            queue.sync {                requestedRanges.append(range)            }            let byteRange = byteRange(fromHeader: range, contentLength: 80_000_000)            let response = HTTPURLResponse(                url: request.url!,                statusCode: 206,                httpVersion: nil,                headerFields: ["Content-Range": "bytes \(byteRange.start)-\(byteRange.end)/80000000"]            )!            return (Data(repeating: 1, count: Int(byteRange.length)), response)        }
            let session = ProgressiveHTTPRangeCacheSession(            fetcher: HTTPRangeRemoteFetcher(                url: URL(string: "https://cdn.example.test/movie.mp4")!,                headers: [:],                session: mockSession()            ),            contentLength: 80_000_000,            durationProvider: { 100 }        )        defer {            session.cancelPrefetch()        }
            session.prefetch(aroundByteOffset: 28_242_716)        _ = try? await session.data(for: HTTPByteRange(start: 51_818_977, end: 52_867_552))        try? await Task.sleep(nanoseconds: 50_000_000)
            let ranges = queue.sync { requestedRanges }        assert(ranges.contains("bytes=51818977-52867552"), "Expected foreground VLC range to be fetched")        assert(ranges.contains { range in            range.hasPrefix("bytes=51936225-")        }, "Expected prefetch to restart near VLC's foreground range, got \(ranges)")        session.cancelPrefetch()        MockURLProtocol.handler = nil        try? await Task.sleep(nanoseconds: 50_000_000)    }
        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)        guard pieces.count == 2,              let start = Int64(pieces[0]) else {            return HTTPByteRange(start: 0, end: 0)        }        let end = pieces[1].isEmpty ? contentLength - 1 : (Int64(pieces[1]) ?? contentLength - 1)        return HTTPByteRange(start: start, end: min(end, contentLength - 1))    }
        private static func testRangeProbeFallsBackWhenServerIgnoresRange() async {        MockURLProtocol.handler = { request in            if request.httpMethod == "HEAD" {

### Related issues or PRs

Related to Beads issue `dreamio-meh` and branch `lavender/vlc-local-range-cache`.

## New Changes as of May 26, 2026 at 12:00 PM EDT

### Summary of changes

Matroska-family streams now bypass the local progressive range cache and open through direct MobileVLCKit playback. This targets a startup buffering regression seen with Torrentio Real-Debrid `.mkv` streams, where VLC asked the loopback cache for both header bytes and far-tail index bytes before normal playback could settle.

### Why this change was made

The runtime log showed `opening mode=local-cache` for an `.mkv`, followed by repeated range-cache fetches around byte zero and near the file tail. Matroska containers often need tail index reads during startup, so the current 1 MB loopback cache behavior can add latency to the exact probes VLC uses to identify tracks and begin playback. Direct VLC playback was the known stable path for these streams.

### Code diffs

Rendered with `@pierre/diffs/ssr`. The patch is intentionally small: one cache eligibility guard and one regression test.

#### Dreamio/ProgressiveHTTPRangeCache.swift

Dreamio/ProgressiveHTTPRangeCache.swift
+8

    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) {52 unmodified lines    private func header(_ name: String, in response: HTTPURLResponse) -> String? {        response.value(forHTTPHeaderField: name)    }}
    enum HTTPRangeCacheError: Error {271 unmodified lines27227327427527627727827928052 unmodified lines333334335336337338339340341342343271 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) {52 unmodified lines    private func header(_ name: String, in response: HTTPURLResponse) -> String? {        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 {

#### Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+27

    42 unmodified lines434445464748491 unmodified lines54054154254354454542 unmodified lines        testRangeCacheForegroundMissFetchesAlignedChunks()        await testRangeCacheForegroundMissReprioritizesPrefetch()        await testRangeCacheHitFollowsActualPostSeekReadArea()        await testRangeProbeFallsBackWhenServerIgnoresRange()        await testRangeFetcherPreservesHeaders()        print("StreamResolverTests passed")491 unmodified lines        try? await Task.sleep(nanoseconds: 50_000_000)    }
        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)42 unmodified lines43444546474849491 unmodified lines54154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157242 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(            url: URL(string: "https://cdn.example.test/show.mkv?token=secret")!,            headers: [:],            session: mockSession()        )        let probe = await fetcher.probe()
            assertEqual(probe.isCacheable, false)        assertEqual(probe.fallbackReason, "tail-index-container")        assertEqual(requestCount, 0)        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

Related to Beads issue `dreamio-816` and branch `lavender/vlc-local-range-cache`.