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

    + +
    +