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
Added a short timeout to the range-cache probe path so a slow HEAD or tiny range request cannot prevent native playback from starting.
+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.
264 unmodified lines2652662672682692702712722732742752762772782 unmodified lines28128228328428528628729 unmodified lines317318319320321322323324325264 unmodified linesself.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") == truelet 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 linesreturn response as? HTTPURLResponse}+private func request(method: String) -> URLRequest {var request = URLRequest(url: url)request.httpMethod = methodheaders.forEach { key, value inrequest.setValue(value, forHTTPHeaderField: key)}264 unmodified lines2652662672682692702712722732742752762772782 unmodified lines28128228328428528628729 unmodified lines317318319320321322323324325326327328264 unmodified linesself.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") == truelet 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 linesreturn response as? HTTPURLResponse}+private func request(method: String, timeoutInterval: TimeInterval? = nil) -> URLRequest {var request = URLRequest(url: url)request.httpMethod = methodif let timeoutInterval {request.timeoutInterval = timeoutInterval}headers.forEach { key, value inrequest.setValue(value, forHTTPHeaderField: key)}
77 unmodified lines7879808182838477 unmodified linesreturn}let fetcher = HTTPRangeRemoteFetcher(url: request.playbackURL, headers: request.headers)let probe = await fetcher.probe()guard !Task.isCancelled else {return}77 unmodified lines7879808182838477 unmodified linesreturn}let fetcher = HTTPRangeRemoteFetcher(url: request.playbackURL, headers: request.headers)let probe = await fetcher.probe(timeoutInterval: 1.5)guard !Task.isCancelled else {return}
43 unmodified lines444546474849522 unmodified lines57257357457557657743 unmodified linesawait testRangeCacheForegroundMissReprioritizesPrefetch()await testRangeCacheHitFollowsActualPostSeekReadArea()await testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges()await testRangeProbeFallsBackWhenServerIgnoresRange()await testRangeFetcherPreservesHeaders()print("StreamResolverTests passed")522 unmodified linesMockURLProtocol.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 lines44454647484950522 unmodified lines57357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360443 unmodified linesawait testRangeCacheForegroundMissReprioritizesPrefetch()await testRangeCacheHitFollowsActualPostSeekReadArea()await testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges()await testRangeProbeAppliesRequestTimeout()await testRangeProbeFallsBackWhenServerIgnoresRange()await testRangeFetcherPreservesHeaders()print("StreamResolverTests passed")522 unmodified linesMockURLProtocol.handler = nil}+private static func testRangeProbeAppliesRequestTimeout() async {MockURLProtocol.handler = { request inassertEqual(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)
Beads issue: dreamio-btc. This follows the earlier dreamio-3sw MKV cache enablement work.
swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/ProgressiveHTTPRangeCache.swift Dreamio/ExternalSubtitleTrackParser.swift Tests/StreamResolverTests.swift -o /tmp/StreamResolverTests && /tmp/StreamResolverTestsDEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build