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

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

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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct 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 lines
48
49
50
34 unmodified lines
99
51 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 lines
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
34 unmodified lines
124
125
126
127
51 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 lines
237 unmodified lines
23 unmodified lines
237 unmodified lines
23 unmodified lines
24
25
26
27
28
29
237 unmodified lines
273
274
275
276
277
278
279
280
281
23 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 lines
71 unmodified lines
140 unmodified lines
14 unmodified lines
71 unmodified lines
140 unmodified lines
14 unmodified lines
15
71 unmodified lines
93
140 unmodified lines
240
14 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

Issues, Limitations, and Mitigations

Follow-up Work

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 lines
115
116
117
118
119
144 unmodified lines
266
267
174 unmodified lines
446
447
448
449
450
451
452
453
454
114 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 lines
115
116
117
118
119
120
121
122
123
124
125
126
127
128
144 unmodified lines
278
279
280
281
282
283
284
285
286
287
174 unmodified lines
466
467
468
469
470
471
472
473
474
475
114 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 lines
319
320
320 unmodified lines
assertEqual(store.cachedRanges, [HTTPByteRange(start: 10, end: 12)])
}
320 unmodified lines
321
322
323
324
325
326
327
328
329
330
331
332
333
334
320 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 lines
16
17
18
19
20
21
232 unmodified lines
254
255
256
257
258
259
28 unmodified lines
288
289
290
291
292
293
294
295
296
297
1 unmodified line
299
300
301
302
303
304
305
306
307
2 unmodified lines
310
311
312
313
314
315
316
317
318
319
320
321
322
323
9 unmodified lines
333
334
335
336
337
338
15 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 = fetcher
28 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 lines
16
17
18
19
20
21
22
23
24
25
232 unmodified lines
258
259
260
261
262
263
264
28 unmodified lines
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
1 unmodified line
310
311
312
313
314
315
316
317
318
319
2 unmodified lines
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
9 unmodified lines
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
15 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 = fetcher
28 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 lines
36
37
38
39
40
41
302 unmodified lines
344
345
346
347
348
349
35 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 lines
36
37
38
39
40
41
42
302 unmodified lines
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
35 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 lines
259
260
261
262
263
264
1 unmodified line
266
267
268
269
270
271
6 unmodified lines
278
279
280
281
282
283
284
285
286
6 unmodified lines
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
24 unmodified lines
333
334
335
336
337
338
339
340
341
342
343
344
345
346
258 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 = fetcher
1 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 lines
259
260
261
262
263
264
265
1 unmodified line
267
268
269
270
271
272
273
274
275
276
6 unmodified lines
283
284
285
286
287
288
289
290
291
292
6 unmodified lines
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
24 unmodified lines
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
258 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 = fetcher
1 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 lines
37
38
39
40
41
42
324 unmodified lines
367
368
369
370
371
372
36 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 lines
37
38
39
40
41
42
43
324 unmodified lines
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
36 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.