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.swiftwith range parsing, sparse cached byte storage, remote range fetching, prefetch window logic, and a small loopback HTTP server. - Updated
VLCNativePlaybackBackendto 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
HEADfirst, then a tinyRange: bytes=0-0request if needed. - The local server responds to VLC range requests with
206 Partial Contentand 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-cachingvalue andhttp-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
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
51 unmodified lines48495034 unmodified lines9951 unmodified lineslet media = VLCMedia(url: request.playbackURL)mediaPlayer.media = mediamediaPlayer.play()34 unmodified linesmediaPlayer.position = max(0, min(1, position))51 unmodified lines5253545556575859606162636465666768697071727334 unmodified lines12412512612751 unmodified linesplaybackStartupTask = Task { [weak self] inlet 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 = sessionsession.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 lineslet 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
23 unmodified lines237 unmodified lines23 unmodified lines237 unmodified lines23 unmodified lines242526272829237 unmodified lines27327427527627727827928028123 unmodified linestestContentRangeParsing()testSparseRangeStoreMergesOverlaps()testSparseRangeStoreHitPartialHitAndMiss()testSparseRangeStoreEvictsOutsideWindow()await testRangeProbeFallsBackWhenServerIgnoresRange()await testRangeFetcherPreservesHeaders()237 unmodified linesprivate static func testRangeFetcherPreservesHeaders() async {MockURLProtocol.handler = { request inassertEqual(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
14 unmodified lines71 unmodified lines140 unmodified lines14 unmodified lines71 unmodified lines140 unmodified lines14 unmodified lines1571 unmodified lines93140 unmodified lines24014 unmodified lines6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */; };71 unmodified lines6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */,140 unmodified lines6F2A2B522C00100100DREAMIO /* 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/StreamResolverTestspassed.pod installrestored missing CocoaPods support files for the worktree.xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS Simulator' buildpassed.
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
114 unmodified lines115116117118119144 unmodified lines266267174 unmodified lines446447448449450451452453454114 unmodified linesfunc evict(keeping window: HTTPByteRange) {lock.withLock {segments.removeAll { !$0.range.overlapsOrTouches(window) }}}144 unmodified linesreturn store.data(for: bounded) ?? data}174 unmodified lineslet 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 linesfunc evict(keeping window: HTTPByteRange) {lock.withLock {segments = segments.compactMap { segment inguard 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 linesreturn 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 lineslet 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",
320 unmodified lines319320320 unmodified linesassertEqual(store.cachedRanges, [HTTPByteRange(start: 10, end: 12)])}320 unmodified lines321322323324325326327328329330331332333334320 unmodified linesassertEqual(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
15 unmodified lines161718192021232 unmodified lines25425525625725825928 unmodified lines2882892902912922932942952962971 unmodified line2993003013023033043053063072 unmodified lines3103113123133143153163173183193203213223239 unmodified lines33333433533633733815 unmodified linesfunc merged(with other: HTTPByteRange) -> HTTPByteRange {HTTPByteRange(start: min(start, other.start), end: max(end, other.end))}}struct HTTPContentRange: Equatable {232 unmodified linesprivate let prefetchChunkSize: Int64 = 1_048_576private let responseChunkSize: Int64 = 1_048_576private 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 lineguard let self else {return}var cursor = window.startwhile 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 linesprint("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)")#endif} catch {#if DEBUGprint("[DreamioRangeCache] prefetch failed range=\(chunk.start)-\(chunk.end) error=\(error)")#endifreturn}}cursor = chunk.end + 1}}}9 unmodified linesreturn 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 linesfunc 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 linesprivate let prefetchChunkSize: Int64 = 1_048_576private let responseChunkSize: Int64 = 1_048_576private 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 = windowstore.evict(keeping: window)guard !store.hasData(for: window) else {activePrefetchWindow = nilreturn}1 unmodified lineguard 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 linesprint("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)")#endif} catch {if Task.isCancelled {return}#if DEBUGprint("[DreamioRangeCache] prefetch failed range=\(chunk.start)-\(chunk.end) error=\(error)")#endifreturn}}}self.activePrefetchWindow = nil}}9 unmodified linesreturn 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) * prefetchChunkSizevar chunks: [HTTPByteRange] = []var cursor = preferredStartwhile cursor <= window.end {let chunk = HTTPByteRange(start: cursor, end: min(window.end, cursor + prefetchChunkSize - 1))chunks.append(chunk)cursor = chunk.end + 1}cursor = window.startwhile 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
35 unmodified lines363738394041302 unmodified lines34434534634734834935 unmodified linestestSparseRangeStoreEvictsOutsideWindow()testSparseRangeStoreTrimsOverlappingWindow()testRangeCacheSessionCapsResponseRange()await testRangeProbeFallsBackWhenServerIgnoresRange()await testRangeFetcherPreservesHeaders()print("StreamResolverTests passed")302 unmodified linesassertEqual(responseRange, HTTPByteRange(start: 0, end: 1_048_575))}private static func testRangeProbeFallsBackWhenServerIgnoresRange() async {MockURLProtocol.handler = { request inif request.httpMethod == "HEAD" {35 unmodified lines36373839404142302 unmodified lines34534634734834935035135235335435535635735835936036136236336436536636736836937037137235 unmodified linestestSparseRangeStoreEvictsOutsideWindow()testSparseRangeStoreTrimsOverlappingWindow()testRangeCacheSessionCapsResponseRange()testRangeCachePrefetchPrioritizesSeekOffset()await testRangeProbeFallsBackWhenServerIgnoresRange()await testRangeFetcherPreservesHeaders()print("StreamResolverTests passed")302 unmodified linesassertEqual(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 inif 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
258 unmodified lines2592602612622632641 unmodified line2662672682692702716 unmodified lines2782792802812822832842852866 unmodified lines29329429529629729829930030130230330430530630730824 unmodified lines333334335336337338339340341342343344345346258 unmodified linesprivate let responseChunkSize: Int64 = 1_048_576private var prefetchTask: Task<Void, Never>?private var activePrefetchWindow: HTTPByteRange?init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {self.fetcher = fetcher1 unmodified lineself.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 DEBUGprint("[DreamioRangeCache] cache=miss range=\(bounded.start)-\(bounded.end)")#endiflet 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 = windowstore.evict(keeping: window)guard !store.hasData(for: window) else {activePrefetchWindow = nilreturn}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 linesprivate let responseChunkSize: Int64 = 1_048_576private 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 lineself.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 DEBUGprint("[DreamioRangeCache] cache=miss range=\(bounded.start)-\(bounded.end)")#endifcancelPrefetchIfNeeded(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 = windowactivePrefetchPreferredOffset = offsetstore.evict(keeping: window)guard !store.hasData(for: window) else {activePrefetchWindow = nilactivePrefetchPreferredOffset = nilreturn}24 unmodified lines}}self.activePrefetchWindow = nilself.activePrefetchPreferredOffset = nil}}func cancelPrefetch() {prefetchTask?.cancel()activePrefetchWindow = nilactivePrefetchPreferredOffset = 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 DEBUGprint("[DreamioRangeCache] prefetch reprioritize from=\(preferredOffset) to=\(range.start)")#endifcancelPrefetch()}private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {let bytesPerSecond = estimatedBytesPerSecond()let behind = max(prefetchChunkSize, bytesPerSecond * 30)
Tests/StreamResolverTests.swift
36 unmodified lines373839404142324 unmodified lines36736836937037137236 unmodified linestestSparseRangeStoreTrimsOverlappingWindow()testRangeCacheSessionCapsResponseRange()testRangeCachePrefetchPrioritizesSeekOffset()await testRangeProbeFallsBackWhenServerIgnoresRange()await testRangeFetcherPreservesHeaders()print("StreamResolverTests passed")324 unmodified lines])}private static func testRangeProbeFallsBackWhenServerIgnoresRange() async {MockURLProtocol.handler = { request inif request.httpMethod == "HEAD" {36 unmodified lines37383940414243324 unmodified lines36836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842936 unmodified linestestSparseRangeStoreTrimsOverlappingWindow()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 inlet 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 inrange.hasPrefix("bytes=51936225-")}, "Expected prefetch to restart near VLC's foreground range, got \(ranges)")session.cancelPrefetch()MockURLProtocol.handler = niltry? 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 inif request.httpMethod == "HEAD" {
Related issues or PRs
Related to Beads issue dreamio-meh and branch lavender/vlc-local-range-cache.