From 46a52b533f9b9f5b87a9756de41ba29cf2db253c Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Tue, 26 May 2026 00:56:24 -0400 Subject: [PATCH] bypass range cache for mkv playback --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/ProgressiveHTTPRangeCache.swift | 8 + Tests/StreamResolverTests.swift | 27 ++++ .../2026-05-25-vlc-local-range-cache.html | 148 ++++++++++++++++++ 5 files changed, 185 insertions(+) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index afa3c8c..6ce3541 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -47,3 +47,4 @@ {"id":"int-56a87fde","kind":"field_change","created_at":"2026-05-26T04:35:35.693504Z","actor":"dirtydishes","issue_id":"dreamio-3pn","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented backward-biased seek priming, global 1 MB range-cache chunk alignment, bounded protected eviction, partial foreground miss fetching/logging, main-actor VLC delegate handling, tests, and turn documentation."}} {"id":"int-91b3db21","kind":"field_change","created_at":"2026-05-26T04:40:10.299245Z","actor":"dirtydishes","issue_id":"dreamio-mi1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Used actual foreground VLC reads as prefetch follow signals on hits and changed foreground misses to fetch aligned chunks; added regression tests and updated the turn document."}} {"id":"int-ff0aeb09","kind":"field_change","created_at":"2026-05-26T04:47:44.48931Z","actor":"dirtydishes","issue_id":"dreamio-2hw","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed stale local range-cache prefetch state after cached seek reads and documented the validation."}} +{"id":"int-204223f5","kind":"field_change","created_at":"2026-05-26T04:56:13.920284Z","actor":"dirtydishes","issue_id":"dreamio-816","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Bypassed the local range cache for Matroska-family tail-index containers and added a regression test confirming MKV probes fall back to direct VLC playback without issuing cache probe requests."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4249ce3..a9fbbae 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,6 @@ {"_type":"issue","id":"dreamio-mun","title":"fix vlc cache loopback port startup","description":"Device logs showed local-cache playback opening http://127.0.0.1:0, because the NWListener ephemeral port was read before the listener reached ready. Wait for the real assigned port before returning the local cache URL.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:32:41Z","created_by":"dirtydishes","updated_at":"2026-05-25T22:33:15Z","started_at":"2026-05-25T22:33:14Z","closed_at":"2026-05-25T22:33:15Z","close_reason":"Wait for NWListener ready state before returning the local cache URL; verified tests and simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-816","title":"Fix local range cache playback buffering","description":"Normal VLC playback can stay in buffering after the local progressive HTTP range cache is enabled. Logs show VLC repeatedly probes header/tail MKV ranges through the loopback server while the cache foreground fetch path serializes 1 MB remote requests. Investigate and adjust the cache path so normal direct-file playback can start reliably.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T04:54:13Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:56:14Z","started_at":"2026-05-26T04:54:17Z","closed_at":"2026-05-26T04:56:14Z","close_reason":"Bypassed the local range cache for Matroska-family tail-index containers and added a regression test confirming MKV probes fall back to direct VLC playback without issuing cache probe requests.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-2hw","title":"Fix range cache prefetch cursor after cached seek reads","description":"Skipping after the local range cache has warmed can leave prefetch following an older foreground cursor instead of the post-seek cached read position. Update the cache so cached foreground reads can reset the follow cursor and add regression coverage.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T04:45:44Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:47:44Z","started_at":"2026-05-26T04:46:36Z","closed_at":"2026-05-26T04:47:44Z","close_reason":"Fixed stale local range-cache prefetch state after cached seek reads and documented the validation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-mi1","title":"adapt vlc prefetch to actual post-seek reads","description":"Use real foreground VLC reads after a seek as a prefetch signal even when they are cache hits, and fetch aligned chunks for partial foreground misses so the cache warms ahead before VLC reaches the edge of retained data.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T04:38:14Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:40:10Z","started_at":"2026-05-26T04:38:16Z","closed_at":"2026-05-26T04:40:10Z","close_reason":"Used actual foreground VLC reads as prefetch follow signals on hits and changed foreground misses to fetch aligned chunks; added regression tests and updated the turn document.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-3pn","title":"reduce vlc seek buffering with range cache priming","description":"Improve VLC local range cache behavior after seek/jump by priming bytes behind the target, using stable global chunk boundaries, retaining useful cached ranges under a byte budget, and adding tests for the observed post-seek request pattern.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T04:31:46Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:35:36Z","started_at":"2026-05-26T04:31:51Z","closed_at":"2026-05-26T04:35:36Z","close_reason":"Implemented backward-biased seek priming, global 1 MB range-cache chunk alignment, bounded protected eviction, partial foreground miss fetching/logging, main-actor VLC delegate handling, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/ProgressiveHTTPRangeCache.swift b/Dreamio/ProgressiveHTTPRangeCache.swift index fffdbc4..776789c 100644 --- a/Dreamio/ProgressiveHTTPRangeCache.swift +++ b/Dreamio/ProgressiveHTTPRangeCache.swift @@ -272,6 +272,9 @@ final class HTTPRangeRemoteFetcher { 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) { @@ -330,6 +333,11 @@ final class HTTPRangeRemoteFetcher { 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 { diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index 368e877..8f3dab7 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -43,6 +43,7 @@ struct StreamResolverTests { testRangeCacheForegroundMissFetchesAlignedChunks() await testRangeCacheForegroundMissReprioritizesPrefetch() await testRangeCacheHitFollowsActualPostSeekReadArea() + await testRangeProbeBypassesTailIndexContainers() await testRangeProbeFallsBackWhenServerIgnoresRange() await testRangeFetcherPreservesHeaders() print("StreamResolverTests passed") @@ -540,6 +541,32 @@ struct StreamResolverTests { 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) diff --git a/docs/turns/2026-05-25-vlc-local-range-cache.html b/docs/turns/2026-05-25-vlc-local-range-cache.html index 4071930..1aa73c2 100644 --- a/docs/turns/2026-05-25-vlc-local-range-cache.html +++ b/docs/turns/2026-05-25-vlc-local-range-cache.html @@ -727,6 +727,154 @@
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 lines
272
273
274
275
276
277
52 unmodified lines
330
331
332
333
334
335
271 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 lines
272
273
274
275
276
277
278
279
280
52 unmodified lines
333
334
335
336
337
338
339
340
341
342
343
271 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 lines
43
44
45
46
47
48
491 unmodified lines
540
541
542
543
544
545
42 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 lines
43
44
45
46
47
48
49
491 unmodified lines
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
42 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.

+
+