From b16857a9fa8fcabfccff22a1b1bad360890aa89f Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Tue, 26 May 2026 01:21:42 -0400 Subject: [PATCH 1/4] update beads --- .beads/issues.jsonl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a9fbbae..8291585 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -42,6 +42,8 @@ {"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-p8p","title":"Recreate OpenSubtitles language turn doc with template","description":"Rebuild the OpenSubtitles caption-track turn document using the new lavender template and contained Clean SSR diff shells.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T05:16:17Z","created_by":"dirtydishes","updated_at":"2026-05-26T05:19:00Z","started_at":"2026-05-26T05:16:21Z","closed_at":"2026-05-26T05:19:00Z","close_reason":"Recreated the OpenSubtitles language turn document using the new lavender template and contained Clean SSR diff shells.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-h28","title":"Clean up turn document template guidance","description":"Add a reusable turn document template and tighten repository instructions so future turn docs use contained clean SSR diffs instead of raw generated diff blobs.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T05:07:57Z","created_by":"dirtydishes","updated_at":"2026-05-26T05:11:37Z","started_at":"2026-05-26T05:08:01Z","closed_at":"2026-05-26T05:11:37Z","close_reason":"Added the reusable turn document template and updated repository instructions for clean SSR diff rendering.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-3yb","title":"Add VLC seek buffer for native playback","description":"Configure balanced VLC media caching in the native playback backend so short seek jumps are less likely to feel like stream restarts while preserving existing playback controls, audio tracks, and subtitles.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T19:05:27Z","created_by":"dirtydishes","updated_at":"2026-05-25T19:07:24Z","started_at":"2026-05-25T19:05:31Z","closed_at":"2026-05-25T19:07:24Z","close_reason":"Added centralized 30-second VLC media caching for native playback and validated the iOS build.","comments":[{"id":"019e60b6-9b11-7c5f-af57-c9e54ac6129f","issue_id":"dreamio-3yb","author":"dirtydishes","text":"Device testing still shows repeated VLC buffering after 15-second jumps. Added DEBUG playback snapshots on state changes and delayed post-jump probes so the next pass can distinguish a stalled stream/range reconnect from a seek-state issue.","created_at":"2026-05-25T19:57:21Z"},{"id":"019e60c4-f824-79fe-b974-9fbe9fe91788","issue_id":"dreamio-3yb","author":"dirtydishes","text":"Latest device logs show VLC remains at the pre-jump time/position after a fixed skip while buffering. Added backend-only stalled jump recovery: after a short no-progress buffering window, reopen the same media with :start-time set to the requested target and reattach accepted subtitle candidates plus selected tracks where available.","created_at":"2026-05-25T20:13:02Z"}],"dependency_count":0,"dependent_count":0,"comment_count":2} {"_type":"issue","id":"dreamio-kdf","title":"Stop tracking Xcode user state","description":"Xcode user interface state files are machine-specific and currently tracked, which causes noisy local modifications and pull conflicts. Remove tracked xcuserstate files from the git index while keeping ignore rules in place.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T18:31:36Z","created_by":"dirtydishes","updated_at":"2026-05-25T18:31:51Z","started_at":"2026-05-25T18:31:39Z","closed_at":"2026-05-25T18:31:51Z","close_reason":"Tracked Xcode user interface state files were removed from the git index, and existing ignore rules now cover regenerated xcuserdata files.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-0lt","title":"add audio track selection","description":"Add native player support for viewing and switching available audio tracks during playback.","status":"closed","priority":2,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-25T17:00:53Z","created_by":"dirtydishes","updated_at":"2026-05-25T17:01:33Z","closed_at":"2026-05-25T17:01:33Z","close_reason":"not implementing now; user asked only to move previous work to the audio-track-selection branch","dependency_count":0,"dependent_count":0,"comment_count":0} From 4d0e675aa362358944be3c362de1aeeaff8b7543 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Tue, 26 May 2026 08:10:39 -0400 Subject: [PATCH 2/4] let mkv streams use the range cache --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/ProgressiveHTTPRangeCache.swift | 8 - Tests/StreamResolverTests.swift | 21 +- .../2026-05-26-fix-vlc-range-cache-mkv.html | 251 ++++++++++++++++++ 5 files changed, 266 insertions(+), 16 deletions(-) create mode 100644 docs/turns/2026-05-26-fix-vlc-range-cache-mkv.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 6ce3541..0d70e4d 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -48,3 +48,4 @@ {"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."}} +{"id":"int-b6f641ed","kind":"field_change","created_at":"2026-05-26T12:10:16.392655Z","actor":"dirtydishes","issue_id":"dreamio-3sw","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Removed the Matroska/WebM extension-level range-cache bypass and added a regression test proving MKV URLs use the cache when the origin advertises byte-range support."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8291585..4481f84 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-3sw","title":"Fix VLC range cache fallback for tail-index MKV streams","description":"Video range caching currently refuses streams classified as tail-index containers, causing VLC playback to use direct mode and lose seek prefetch behavior. Investigate the probe logic and enable safe local range caching for these streams without breaking playback startup.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T12:05:20Z","created_by":"dirtydishes","updated_at":"2026-05-26T12:10:16Z","started_at":"2026-05-26T12:05:38Z","closed_at":"2026-05-26T12:10:16Z","close_reason":"Removed the Matroska/WebM extension-level range-cache bypass and added a regression test proving MKV URLs use the cache when the origin advertises byte-range support.","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} diff --git a/Dreamio/ProgressiveHTTPRangeCache.swift b/Dreamio/ProgressiveHTTPRangeCache.swift index 776789c..d2eb203 100644 --- a/Dreamio/ProgressiveHTTPRangeCache.swift +++ b/Dreamio/ProgressiveHTTPRangeCache.swift @@ -272,10 +272,6 @@ 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) { let acceptsRanges = header("Accept-Ranges", in: head)?.lowercased().contains("bytes") == true @@ -334,10 +330,6 @@ final class HTTPRangeRemoteFetcher { 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 8f3dab7..bb9d71c 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -43,7 +43,7 @@ struct StreamResolverTests { testRangeCacheForegroundMissFetchesAlignedChunks() await testRangeCacheForegroundMissReprioritizesPrefetch() await testRangeCacheHitFollowsActualPostSeekReadArea() - await testRangeProbeBypassesTailIndexContainers() + await testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges() await testRangeProbeFallsBackWhenServerIgnoresRange() await testRangeFetcherPreservesHeaders() print("StreamResolverTests passed") @@ -541,17 +541,21 @@ struct StreamResolverTests { try? await Task.sleep(nanoseconds: 50_000_000) } - private static func testRangeProbeBypassesTailIndexContainers() async { + private static func testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges() async { var requestCount = 0 MockURLProtocol.handler = { request in requestCount += 1 + assertEqual(request.httpMethod, "HEAD") let response = HTTPURLResponse( url: request.url!, - statusCode: 206, + statusCode: 200, httpVersion: nil, - headerFields: ["Content-Range": "bytes 0-0/20"] + headerFields: [ + "Accept-Ranges": "bytes", + "Content-Length": "20" + ] )! - return (Data([1]), response) + return (Data(), response) } let fetcher = HTTPRangeRemoteFetcher( @@ -561,9 +565,10 @@ struct StreamResolverTests { ) let probe = await fetcher.probe() - assertEqual(probe.isCacheable, false) - assertEqual(probe.fallbackReason, "tail-index-container") - assertEqual(requestCount, 0) + assertEqual(probe.isCacheable, true) + assertEqual(probe.contentLength, 20) + assertEqual(probe.fallbackReason, nil) + assertEqual(requestCount, 1) MockURLProtocol.handler = nil } diff --git a/docs/turns/2026-05-26-fix-vlc-range-cache-mkv.html b/docs/turns/2026-05-26-fix-vlc-range-cache-mkv.html new file mode 100644 index 0000000..2331755 --- /dev/null +++ b/docs/turns/2026-05-26-fix-vlc-range-cache-mkv.html @@ -0,0 +1,251 @@ + + + + + + Fix VLC Range Cache for MKV Streams + + + +
+
+
Dreamio turn documentation ยท May 26, 2026
+

Fix VLC Range Cache for MKV Streams

+

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

+
+ +
+

Summary

+

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

+
+ +
+

Changes Made

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

Context

+

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

+
+ +
+

Important Implementation Details

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

Relevant Diff Snippets

+

Dreamio/ProgressiveHTTPRangeCache.swift

Dreamio/ProgressiveHTTPRangeCache.swift
-8
271 unmodified lines
272
273
274
275
276
277
278
279
280
281
52 unmodified lines
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) {
let acceptsRanges = header("Accept-Ranges", in: head)?.lowercased().contains("bytes") == true
52 unmodified lines
response.value(forHTTPHeaderField: name)
}
+
private static func shouldBypassCache(for url: URL) -> Bool {
let extensionName = url.pathExtension.lowercased()
return ["mkv", "mk3d", "mka", "mks", "webm"].contains(extensionName)
}
}
+
enum HTTPRangeCacheError: Error {
271 unmodified 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) {
let acceptsRanges = header("Accept-Ranges", in: head)?.lowercased().contains("bytes") == true
52 unmodified lines
response.value(forHTTPHeaderField: name)
}
+
}
+
enum HTTPRangeCacheError: Error {

Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
-8+13
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
3 unmodified lines
561
562
563
564
565
566
567
568
569
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(
3 unmodified lines
)
let probe = await fetcher.probe()
+
assertEqual(probe.isCacheable, false)
assertEqual(probe.fallbackReason, "tail-index-container")
assertEqual(requestCount, 0)
MockURLProtocol.handler = nil
}
+
42 unmodified 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
3 unmodified lines
565
566
567
568
569
570
571
572
573
574
42 unmodified lines
testRangeCacheForegroundMissFetchesAlignedChunks()
await testRangeCacheForegroundMissReprioritizesPrefetch()
await testRangeCacheHitFollowsActualPostSeekReadArea()
await testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges()
await testRangeProbeFallsBackWhenServerIgnoresRange()
await testRangeFetcherPreservesHeaders()
print("StreamResolverTests passed")
491 unmodified lines
try? await Task.sleep(nanoseconds: 50_000_000)
}
+
private static func testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges() async {
var requestCount = 0
MockURLProtocol.handler = { request in
requestCount += 1
assertEqual(request.httpMethod, "HEAD")
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: [
"Accept-Ranges": "bytes",
"Content-Length": "20"
]
)!
return (Data(), response)
}
+
let fetcher = HTTPRangeRemoteFetcher(
3 unmodified lines
)
let probe = await fetcher.probe()
+
assertEqual(probe.isCacheable, true)
assertEqual(probe.contentLength, 20)
assertEqual(probe.fallbackReason, nil)
assertEqual(requestCount, 1)
MockURLProtocol.handler = nil
}
+
+
+ +
+

Expected Impact for End-Users

+

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

+
+ +
+

Validation

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

Issues, Limitations, and Mitigations

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

Follow-up Work

+
    +
  • Test the original Torrentio/Real-Debrid South Park stream on device and confirm logs show opening mode=local-cache.
  • +
  • If startup is slower on some MKV sources, consider measuring HEAD latency and falling back to the tiny range probe sooner.
  • +
  • Improve external subtitle auto-selection so English does not lose to the first parsed subtitle track.
  • +
+
+
+ + From 4b173e0b885dbeac2d590433087216daf1d94206 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Tue, 26 May 2026 08:17:07 -0400 Subject: [PATCH 3/4] bound range cache probe startup --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/ProgressiveHTTPRangeCache.swift | 11 +- Dreamio/VLCNativePlaybackBackend.swift | 2 +- Tests/StreamResolverTests.swift | 27 +++ .../2026-05-26-fix-vlc-range-cache-mkv.html | 217 ++++++++++++++++++ 6 files changed, 254 insertions(+), 5 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 0d70e4d..80ecff8 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -49,3 +49,4 @@ {"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."}} {"id":"int-b6f641ed","kind":"field_change","created_at":"2026-05-26T12:10:16.392655Z","actor":"dirtydishes","issue_id":"dreamio-3sw","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Removed the Matroska/WebM extension-level range-cache bypass and added a regression test proving MKV URLs use the cache when the origin advertises byte-range support."}} +{"id":"int-2b073805","kind":"field_change","created_at":"2026-05-26T12:16:53.567972Z","actor":"dirtydishes","issue_id":"dreamio-btc","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added a short timeout to range-cache probe requests so slow MKV HEAD/range probes fall back to direct VLC startup instead of tripping the native-player startup timeout."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4481f84..e1289f0 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_type":"issue","id":"dreamio-btc","title":"Bound VLC range cache probe startup latency","description":"After enabling MKV range cache probing, some Torrentio/Real-Debrid MKV streams log cache-probe but never reach opening mode before the native-player startup timeout. Add a bounded probe/local-cache startup path that falls back to direct playback when the range probe is slow or inconclusive.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T12:14:02Z","created_by":"dirtydishes","updated_at":"2026-05-26T12:16:53Z","started_at":"2026-05-26T12:14:11Z","closed_at":"2026-05-26T12:16:53Z","close_reason":"Added a short timeout to range-cache probe requests so slow MKV HEAD/range probes fall back to direct VLC startup instead of tripping the native-player startup timeout.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_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-3sw","title":"Fix VLC range cache fallback for tail-index MKV streams","description":"Video range caching currently refuses streams classified as tail-index containers, causing VLC playback to use direct mode and lose seek prefetch behavior. Investigate the probe logic and enable safe local range caching for these streams without breaking playback startup.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T12:05:20Z","created_by":"dirtydishes","updated_at":"2026-05-26T12:10:16Z","started_at":"2026-05-26T12:05:38Z","closed_at":"2026-05-26T12:10:16Z","close_reason":"Removed the Matroska/WebM extension-level range-cache bypass and added a regression test proving MKV URLs use the cache when the origin advertises byte-range support.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/ProgressiveHTTPRangeCache.swift b/Dreamio/ProgressiveHTTPRangeCache.swift index d2eb203..9ae37cd 100644 --- a/Dreamio/ProgressiveHTTPRangeCache.swift +++ b/Dreamio/ProgressiveHTTPRangeCache.swift @@ -265,14 +265,14 @@ final class HTTPRangeRemoteFetcher { self.session = session } - func probe() async -> HTTPRangeProbeResult { + func probe(timeoutInterval: TimeInterval = 3) async -> HTTPRangeProbeResult { guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "non-http-url") } guard !url.path.lowercased().hasSuffix(".m3u8") else { return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "hls-playlist") } - if let head = try? await response(for: request(method: "HEAD")), + if let head = try? await response(for: request(method: "HEAD", timeoutInterval: timeoutInterval)), (200..<400).contains(head.statusCode) { let acceptsRanges = header("Accept-Ranges", in: head)?.lowercased().contains("bytes") == true let length = header("Content-Length", in: head).flatMap(Int64.init) @@ -281,7 +281,7 @@ final class HTTPRangeRemoteFetcher { } } - var tinyRequest = request(method: "GET") + var tinyRequest = request(method: "GET", timeoutInterval: timeoutInterval) tinyRequest.setValue("bytes=0-0", forHTTPHeaderField: "Range") do { let (data, response) = try await session.data(for: tinyRequest) @@ -317,9 +317,12 @@ final class HTTPRangeRemoteFetcher { return response as? HTTPURLResponse } - private func request(method: String) -> URLRequest { + private func request(method: String, timeoutInterval: TimeInterval? = nil) -> URLRequest { var request = URLRequest(url: url) request.httpMethod = method + if let timeoutInterval { + request.timeoutInterval = timeoutInterval + } headers.forEach { key, value in request.setValue(value, forHTTPHeaderField: key) } diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index 1bb340a..d9ee3cf 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -78,7 +78,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { return } let fetcher = HTTPRangeRemoteFetcher(url: request.playbackURL, headers: request.headers) - let probe = await fetcher.probe() + let probe = await fetcher.probe(timeoutInterval: 1.5) guard !Task.isCancelled else { return } diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index bb9d71c..7c55364 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -44,6 +44,7 @@ struct StreamResolverTests { await testRangeCacheForegroundMissReprioritizesPrefetch() await testRangeCacheHitFollowsActualPostSeekReadArea() await testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges() + await testRangeProbeAppliesRequestTimeout() await testRangeProbeFallsBackWhenServerIgnoresRange() await testRangeFetcherPreservesHeaders() print("StreamResolverTests passed") @@ -572,6 +573,32 @@ struct StreamResolverTests { MockURLProtocol.handler = nil } + private static func testRangeProbeAppliesRequestTimeout() async { + MockURLProtocol.handler = { request in + assertEqual(request.timeoutInterval, 1.5) + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: [ + "Accept-Ranges": "bytes", + "Content-Length": "20" + ] + )! + return (Data(), response) + } + + let fetcher = HTTPRangeRemoteFetcher( + url: URL(string: "https://cdn.example.test/show.mkv")!, + headers: [:], + session: mockSession() + ) + let probe = await fetcher.probe(timeoutInterval: 1.5) + + assertEqual(probe.isCacheable, true) + MockURLProtocol.handler = nil + } + private static func byteRange(fromHeader header: String, contentLength: Int64) -> HTTPByteRange { let value = header.replacingOccurrences(of: "bytes=", with: "") let pieces = value.split(separator: "-", maxSplits: 1).map(String.init) diff --git a/docs/turns/2026-05-26-fix-vlc-range-cache-mkv.html b/docs/turns/2026-05-26-fix-vlc-range-cache-mkv.html index 2331755..5d48b06 100644 --- a/docs/turns/2026-05-26-fix-vlc-range-cache-mkv.html +++ b/docs/turns/2026-05-26-fix-vlc-range-cache-mkv.html @@ -246,6 +246,223 @@ code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.92em; ba
  • Improve external subtitle auto-selection so English does not lose to the first parsed subtitle track.
  • +
    +

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

    +

    Summary of changes

    +

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

    +

    Why this change was made

    +

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

    +

    Code diffs

    +

    Dreamio/ProgressiveHTTPRangeCache.swift

    Dreamio/ProgressiveHTTPRangeCache.swift
    -4+7
    264 unmodified lines
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    2 unmodified lines
    281
    282
    283
    284
    285
    286
    287
    29 unmodified lines
    317
    318
    319
    320
    321
    322
    323
    324
    325
    264 unmodified lines
    self.session = session
    }
    +
    func probe() async -> HTTPRangeProbeResult {
    guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {
    return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "non-http-url")
    }
    guard !url.path.lowercased().hasSuffix(".m3u8") else {
    return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "hls-playlist")
    }
    if let head = try? await response(for: request(method: "HEAD")),
    (200..<400).contains(head.statusCode) {
    let acceptsRanges = header("Accept-Ranges", in: head)?.lowercased().contains("bytes") == true
    let length = header("Content-Length", in: head).flatMap(Int64.init)
    2 unmodified lines
    }
    }
    +
    var tinyRequest = request(method: "GET")
    tinyRequest.setValue("bytes=0-0", forHTTPHeaderField: "Range")
    do {
    let (data, response) = try await session.data(for: tinyRequest)
    29 unmodified lines
    return response as? HTTPURLResponse
    }
    +
    private func request(method: String) -> URLRequest {
    var request = URLRequest(url: url)
    request.httpMethod = method
    headers.forEach { key, value in
    request.setValue(value, forHTTPHeaderField: key)
    }
    264 unmodified lines
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    2 unmodified lines
    281
    282
    283
    284
    285
    286
    287
    29 unmodified lines
    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    264 unmodified lines
    self.session = session
    }
    +
    func probe(timeoutInterval: TimeInterval = 3) async -> HTTPRangeProbeResult {
    guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {
    return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "non-http-url")
    }
    guard !url.path.lowercased().hasSuffix(".m3u8") else {
    return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "hls-playlist")
    }
    if let head = try? await response(for: request(method: "HEAD", timeoutInterval: timeoutInterval)),
    (200..<400).contains(head.statusCode) {
    let acceptsRanges = header("Accept-Ranges", in: head)?.lowercased().contains("bytes") == true
    let length = header("Content-Length", in: head).flatMap(Int64.init)
    2 unmodified lines
    }
    }
    +
    var tinyRequest = request(method: "GET", timeoutInterval: timeoutInterval)
    tinyRequest.setValue("bytes=0-0", forHTTPHeaderField: "Range")
    do {
    let (data, response) = try await session.data(for: tinyRequest)
    29 unmodified lines
    return response as? HTTPURLResponse
    }
    +
    private func request(method: String, timeoutInterval: TimeInterval? = nil) -> URLRequest {
    var request = URLRequest(url: url)
    request.httpMethod = method
    if let timeoutInterval {
    request.timeoutInterval = timeoutInterval
    }
    headers.forEach { key, value in
    request.setValue(value, forHTTPHeaderField: key)
    }
    +

    Dreamio/VLCNativePlaybackBackend.swift

    Dreamio/VLCNativePlaybackBackend.swift
    -1+1
    77 unmodified lines
    78
    79
    80
    81
    82
    83
    84
    77 unmodified lines
    return
    }
    let fetcher = HTTPRangeRemoteFetcher(url: request.playbackURL, headers: request.headers)
    let probe = await fetcher.probe()
    guard !Task.isCancelled else {
    return
    }
    77 unmodified lines
    78
    79
    80
    81
    82
    83
    84
    77 unmodified lines
    return
    }
    let fetcher = HTTPRangeRemoteFetcher(url: request.playbackURL, headers: request.headers)
    let probe = await fetcher.probe(timeoutInterval: 1.5)
    guard !Task.isCancelled else {
    return
    }
    +

    Tests/StreamResolverTests.swift

    Tests/StreamResolverTests.swift
    +27
    43 unmodified lines
    44
    45
    46
    47
    48
    49
    522 unmodified lines
    572
    573
    574
    575
    576
    577
    43 unmodified lines
    await testRangeCacheForegroundMissReprioritizesPrefetch()
    await testRangeCacheHitFollowsActualPostSeekReadArea()
    await testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges()
    await testRangeProbeFallsBackWhenServerIgnoresRange()
    await testRangeFetcherPreservesHeaders()
    print("StreamResolverTests passed")
    522 unmodified lines
    MockURLProtocol.handler = nil
    }
    +
    private static func byteRange(fromHeader header: String, contentLength: Int64) -> HTTPByteRange {
    let value = header.replacingOccurrences(of: "bytes=", with: "")
    let pieces = value.split(separator: "-", maxSplits: 1).map(String.init)
    43 unmodified lines
    44
    45
    46
    47
    48
    49
    50
    522 unmodified lines
    573
    574
    575
    576
    577
    578
    579
    580
    581
    582
    583
    584
    585
    586
    587
    588
    589
    590
    591
    592
    593
    594
    595
    596
    597
    598
    599
    600
    601
    602
    603
    604
    43 unmodified lines
    await testRangeCacheForegroundMissReprioritizesPrefetch()
    await testRangeCacheHitFollowsActualPostSeekReadArea()
    await testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges()
    await testRangeProbeAppliesRequestTimeout()
    await testRangeProbeFallsBackWhenServerIgnoresRange()
    await testRangeFetcherPreservesHeaders()
    print("StreamResolverTests passed")
    522 unmodified lines
    MockURLProtocol.handler = nil
    }
    +
    private static func testRangeProbeAppliesRequestTimeout() async {
    MockURLProtocol.handler = { request in
    assertEqual(request.timeoutInterval, 1.5)
    let response = HTTPURLResponse(
    url: request.url!,
    statusCode: 200,
    httpVersion: nil,
    headerFields: [
    "Accept-Ranges": "bytes",
    "Content-Length": "20"
    ]
    )!
    return (Data(), response)
    }
    +
    let fetcher = HTTPRangeRemoteFetcher(
    url: URL(string: "https://cdn.example.test/show.mkv")!,
    headers: [:],
    session: mockSession()
    )
    let probe = await fetcher.probe(timeoutInterval: 1.5)
    +
    assertEqual(probe.isCacheable, true)
    MockURLProtocol.handler = nil
    }
    +
    private static func byteRange(fromHeader header: String, contentLength: Int64) -> HTTPByteRange {
    let value = header.replacingOccurrences(of: "bytes=", with: "")
    let pieces = value.split(separator: "-", maxSplits: 1).map(String.init)
    + +

    Related issues or PRs

    +

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

    +

    Validation

    +
      +
    • Passed swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/ProgressiveHTTPRangeCache.swift Dreamio/ExternalSubtitleTrackParser.swift Tests/StreamResolverTests.swift -o /tmp/StreamResolverTests && /tmp/StreamResolverTests
    • +
    • Passed DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build
    • +
    +
    + From c4236afe7aa93bd80568abe1dcd11d4a3accbfe0 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Tue, 26 May 2026 09:01:33 -0400 Subject: [PATCH 4/4] start vlc before cache probing --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/VLCNativePlaybackBackend.swift | 60 ++------------ .../2026-05-26-fix-vlc-range-cache-mkv.html | 81 +++++++++++++++++++ 4 files changed, 91 insertions(+), 52 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 80ecff8..1802479 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -50,3 +50,4 @@ {"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."}} {"id":"int-b6f641ed","kind":"field_change","created_at":"2026-05-26T12:10:16.392655Z","actor":"dirtydishes","issue_id":"dreamio-3sw","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Removed the Matroska/WebM extension-level range-cache bypass and added a regression test proving MKV URLs use the cache when the origin advertises byte-range support."}} {"id":"int-2b073805","kind":"field_change","created_at":"2026-05-26T12:16:53.567972Z","actor":"dirtydishes","issue_id":"dreamio-btc","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added a short timeout to range-cache probe requests so slow MKV HEAD/range probes fall back to direct VLC startup instead of tripping the native-player startup timeout."}} +{"id":"int-1ed0a18a","kind":"field_change","created_at":"2026-05-26T13:01:27.690486Z","actor":"dirtydishes","issue_id":"dreamio-dd7","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Changed VLC startup to open direct playback immediately instead of waiting for slow range-cache probes, restoring reliable native-player startup for MKV streams."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index e1289f0..b125b9d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_type":"issue","id":"dreamio-dd7","title":"Start VLC before slow range-cache probes","description":"Current MKV cache probing can still block native VLC startup because HEAD and tiny range timeout sequentially before any media is opened. Start direct playback immediately or otherwise ensure VLC media opens before probing completes, while preserving range-cache support when it is ready quickly.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T13:00:37Z","created_by":"dirtydishes","updated_at":"2026-05-26T13:01:28Z","started_at":"2026-05-26T13:00:43Z","closed_at":"2026-05-26T13:01:28Z","close_reason":"Changed VLC startup to open direct playback immediately instead of waiting for slow range-cache probes, restoring reliable native-player startup for MKV streams.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-btc","title":"Bound VLC range cache probe startup latency","description":"After enabling MKV range cache probing, some Torrentio/Real-Debrid MKV streams log cache-probe but never reach opening mode before the native-player startup timeout. Add a bounded probe/local-cache startup path that falls back to direct playback when the range probe is slow or inconclusive.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T12:14:02Z","created_by":"dirtydishes","updated_at":"2026-05-26T12:16:53Z","started_at":"2026-05-26T12:14:11Z","closed_at":"2026-05-26T12:16:53Z","close_reason":"Added a short timeout to range-cache probe requests so slow MKV HEAD/range probes fall back to direct VLC startup instead of tripping the native-player startup timeout.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_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} diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index d9ee3cf..5a0ee11 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -71,59 +71,15 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { lastLoggedState = nil lastBufferingLogTime = nil #if DEBUG - print("[DreamioVLC] cache-probe url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") + print("[DreamioVLC] cache fallback reason=startup-direct-preferred url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") #endif - playbackStartupTask = Task { [weak self] in - guard let self else { - return - } - let fetcher = HTTPRangeRemoteFetcher(url: request.playbackURL, headers: request.headers) - let probe = await fetcher.probe(timeoutInterval: 1.5) - guard !Task.isCancelled else { - return - } - - if probe.isCacheable, let contentLength = probe.contentLength, contentLength > 0 { - do { - let session = ProgressiveHTTPRangeCacheSession( - fetcher: fetcher, - contentLength: contentLength, - durationProvider: { [weak self] in self?.duration ?? 0 } - ) - let localURL = try await 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 - } catch { -#if DEBUG - print("[DreamioVLC] cache fallback reason=local-server-error-\(error)") -#endif - } - } else { -#if DEBUG - print("[DreamioVLC] cache fallback reason=\(probe.fallbackReason ?? "unknown")") -#endif - } - - await MainActor.run { - self.startVLCMedia( - url: request.playbackURL, - request: request, - playbackMode: "direct", - cachingMilliseconds: 2500, - includeRemoteHTTPOptions: true - ) - } - } + startVLCMedia( + url: request.playbackURL, + request: request, + playbackMode: "direct", + cachingMilliseconds: 2500, + includeRemoteHTTPOptions: true + ) #else onFailure?(NativePlaybackError.backendUnavailable) #endif diff --git a/docs/turns/2026-05-26-fix-vlc-range-cache-mkv.html b/docs/turns/2026-05-26-fix-vlc-range-cache-mkv.html index 5d48b06..5e6db74 100644 --- a/docs/turns/2026-05-26-fix-vlc-range-cache-mkv.html +++ b/docs/turns/2026-05-26-fix-vlc-range-cache-mkv.html @@ -463,6 +463,87 @@ code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.92em; ba +
    +

    New Changes as of May 26, 2026 at 9:01 AM

    +

    Summary of changes

    +

    Changed VLC startup to open direct playback immediately instead of waiting for the range-cache probe.

    +

    Why this change was made

    +

    Device logs showed the probe request timing out and the native player still failing to start before the startup watchdog. The reliable behavior is to start VLC first, then revisit cache probing as a non-blocking optimization later.

    +

    Code diffs

    +

    Dreamio/VLCNativePlaybackBackend.swift

    Dreamio/VLCNativePlaybackBackend.swift
    -53+9
    70 unmodified lines
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    70 unmodified lines
    lastLoggedState = nil
    lastBufferingLogTime = nil
    #if DEBUG
    print("[DreamioVLC] cache-probe url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
    #endif
    playbackStartupTask = Task { [weak self] in
    guard let self else {
    return
    }
    let fetcher = HTTPRangeRemoteFetcher(url: request.playbackURL, headers: request.headers)
    let probe = await fetcher.probe(timeoutInterval: 1.5)
    guard !Task.isCancelled else {
    return
    }
    +
    if probe.isCacheable, let contentLength = probe.contentLength, contentLength > 0 {
    do {
    let session = ProgressiveHTTPRangeCacheSession(
    fetcher: fetcher,
    contentLength: contentLength,
    durationProvider: { [weak self] in self?.duration ?? 0 }
    )
    let localURL = try await 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
    } catch {
    #if DEBUG
    print("[DreamioVLC] cache fallback reason=local-server-error-\(error)")
    #endif
    }
    } else {
    #if DEBUG
    print("[DreamioVLC] cache fallback reason=\(probe.fallbackReason ?? "unknown")")
    #endif
    }
    +
    await MainActor.run {
    self.startVLCMedia(
    url: request.playbackURL,
    request: request,
    playbackMode: "direct",
    cachingMilliseconds: 2500,
    includeRemoteHTTPOptions: true
    )
    }
    }
    #else
    onFailure?(NativePlaybackError.backendUnavailable)
    #endif
    70 unmodified lines
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    70 unmodified lines
    lastLoggedState = nil
    lastBufferingLogTime = nil
    #if DEBUG
    print("[DreamioVLC] cache fallback reason=startup-direct-preferred url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
    #endif
    startVLCMedia(
    url: request.playbackURL,
    request: request,
    playbackMode: "direct",
    cachingMilliseconds: 2500,
    includeRemoteHTTPOptions: true
    )
    #else
    onFailure?(NativePlaybackError.backendUnavailable)
    #endif
    +

    Related issues or PRs

    +

    Beads issue: dreamio-dd7. This supersedes the blocking startup probe behavior from the earlier MKV range-cache experiment.

    +

    Validation

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