From f141d26fb5d8b76ed90dc8aed63ed976f5629a2e Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Tue, 26 May 2026 20:43:25 -0400 Subject: [PATCH] make vlc range cache startup non-blocking --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/ProgressiveHTTPRangeCache.swift | 27 +++ Dreamio/VLCNativePlaybackBackend.swift | 132 +++++++++- Tests/StreamResolverTests.swift | 38 +++ .../2026-05-26-fix-vlc-range-cache-mkv.html | 229 ++++++++++++++++++ 6 files changed, 424 insertions(+), 4 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 1802479..1125955 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -51,3 +51,4 @@ {"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."}} +{"id":"int-ffb67dfa","kind":"field_change","created_at":"2026-05-27T00:43:02.592758Z","actor":"dirtydishes","issue_id":"dreamio-5cz","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented bounded non-blocking range-cache startup for VLC, with direct fallback on timeout, skipped probes, or local server failures; added focused startup policy tests and updated the turn document."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index b125b9d..4fcb183 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -2,6 +2,7 @@ {"_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-5cz","title":"Make VLC range cache non-blocking at startup","description":"Native playback startup currently bypasses Dreamio's local range cache after cache probing caused VLC startup timeouts. Reintroduce cache startup only when preparation is fast and safe, otherwise fall back to direct playback immediately, with focused tests and clear logs.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T00:36:56Z","created_by":"dirtydishes","updated_at":"2026-05-27T00:43:03Z","started_at":"2026-05-27T00:37:03Z","closed_at":"2026-05-27T00:43:03Z","close_reason":"Implemented bounded non-blocking range-cache startup for VLC, with direct fallback on timeout, skipped probes, or local server failures; added focused startup policy tests and updated the turn document.","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} diff --git a/Dreamio/ProgressiveHTTPRangeCache.swift b/Dreamio/ProgressiveHTTPRangeCache.swift index 9ae37cd..8642042 100644 --- a/Dreamio/ProgressiveHTTPRangeCache.swift +++ b/Dreamio/ProgressiveHTTPRangeCache.swift @@ -58,6 +58,33 @@ struct HTTPRangeProbeResult { let fallbackReason: String? } +enum HTTPRangeCacheStartupDecision: Equatable { + case useLocalCache + case skip(reason: String) +} + +enum HTTPRangeCacheStartupPolicy { + static let preparationTimeout: TimeInterval = 0.25 + static let probeTimeout: TimeInterval = 0.2 + + static func immediateSkipReason(for url: URL) -> String? { + guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { + return "non-http-url" + } + guard !url.path.lowercased().hasSuffix(".m3u8") else { + return "hls-playlist" + } + return nil + } + + static func decision(for probe: HTTPRangeProbeResult) -> HTTPRangeCacheStartupDecision { + guard probe.isCacheable, probe.contentLength != nil else { + return .skip(reason: probe.fallbackReason ?? "range-probe-inconclusive") + } + return .useLocalCache + } +} + final class SparseHTTPByteRangeStore { private struct Segment { var range: HTTPByteRange diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index 5a0ee11..bbb97e4 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -25,6 +25,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { #endif private var rangeCacheSession: ProgressiveHTTPRangeCacheSession? private var playbackStartupTask: Task? + private var rangeCachePreparationTask: Task? + private var playbackStartupID: UUID? private var lastLoggedState: String? private var lastBufferingLogTime: Date? private var attachedSubtitleURLs = Set() @@ -56,6 +58,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { func play(request: NativePlaybackRequest) { #if canImport(MobileVLCKit) playbackStartupTask?.cancel() + rangeCachePreparationTask?.cancel() attachedSubtitleURLs.removeAll() pendingSubtitleCandidates.removeAll() pendingSubtitleURLs.removeAll() @@ -70,8 +73,91 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { rangeCacheSession = nil lastLoggedState = nil lastBufferingLogTime = nil + startWithNonBlockingRangeCache(request: request) +#else + onFailure?(NativePlaybackError.backendUnavailable) +#endif + } + +#if canImport(MobileVLCKit) + private func startWithNonBlockingRangeCache(request: NativePlaybackRequest) { + if let skipReason = HTTPRangeCacheStartupPolicy.immediateSkipReason(for: request.playbackURL) { #if DEBUG - print("[DreamioVLC] cache fallback reason=startup-direct-preferred url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") + print("[DreamioVLC] cache skipped reason=\(skipReason) url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") +#endif + startDirectPlayback(request: request, fallbackReason: skipReason) + return + } + + let startupID = UUID() + playbackStartupID = startupID + + playbackStartupTask = Task { [weak self] in + guard let self else { + return + } + do { + let timeoutNanoseconds = UInt64(HTTPRangeCacheStartupPolicy.preparationTimeout * 1_000_000_000) + try await Task.sleep(nanoseconds: timeoutNanoseconds) + } catch { + return + } + await MainActor.run { + guard self.canStartPlayback(for: startupID) else { + return + } +#if DEBUG + print("[DreamioVLC] cache probe timed out timeoutMs=\(Int(HTTPRangeCacheStartupPolicy.preparationTimeout * 1000)) url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") +#endif + self.rangeCachePreparationTask?.cancel() + self.startDirectPlayback(request: request, fallbackReason: "startup-direct-preferred") + } + } + + rangeCachePreparationTask = Task { [weak self] in + guard let self else { + return + } + let result = await self.prepareRangeCache(for: request) + guard !Task.isCancelled else { + return + } + await MainActor.run { + guard self.canStartPlayback(for: startupID) else { + return + } + self.playbackStartupTask?.cancel() + switch result { + case .success(let prepared): +#if DEBUG + print("[DreamioVLC] cache used mode=local-cache url=\(URLRedactor.redactedURLString(prepared.localURL.absoluteString))") +#endif + self.rangeCacheSession = prepared.session + self.startVLCMedia( + url: prepared.localURL, + request: request, + playbackMode: "local-cache", + cachingMilliseconds: 1000, + includeRemoteHTTPOptions: false + ) + case .failure(let failure): + self.startDirectPlayback(request: request, fallbackReason: failure.reason) + } + } + } + } + + private func canStartPlayback(for startupID: UUID) -> Bool { + playbackStartupID == startupID && !hasStartedMedia + } + + private struct RangeCacheStartupFailure: Error { + let reason: String + } + + private func startDirectPlayback(request: NativePlaybackRequest, fallbackReason: String) { +#if DEBUG + print("[DreamioVLC] direct fallback started reason=\(fallbackReason) url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") #endif startVLCMedia( url: request.playbackURL, @@ -80,11 +166,47 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { cachingMilliseconds: 2500, includeRemoteHTTPOptions: true ) -#else - onFailure?(NativePlaybackError.backendUnavailable) -#endif } + private struct PreparedRangeCache { + let session: ProgressiveHTTPRangeCacheSession + let localURL: URL + } + + private func prepareRangeCache(for request: NativePlaybackRequest) async -> Result { + let fetcher = HTTPRangeRemoteFetcher(url: request.playbackURL, headers: request.headers) + let probe = await fetcher.probe(timeoutInterval: HTTPRangeCacheStartupPolicy.probeTimeout) + switch HTTPRangeCacheStartupPolicy.decision(for: probe) { + case .skip(let reason): +#if DEBUG + print("[DreamioVLC] cache skipped reason=\(reason) url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") +#endif + return .failure(RangeCacheStartupFailure(reason: reason)) + case .useLocalCache: + break + } + + guard let contentLength = probe.contentLength else { + return .failure(RangeCacheStartupFailure(reason: "range-probe-inconclusive")) + } + let session = ProgressiveHTTPRangeCacheSession( + fetcher: fetcher, + contentLength: contentLength, + durationProvider: { [weak self] in self?.duration ?? 0 } + ) + do { + let localURL = try await ProgressiveHTTPRangeCacheServer.shared.localURL(for: session) + return .success(PreparedRangeCache(session: session, localURL: localURL)) + } catch { +#if DEBUG + print("[DreamioVLC] local cache server failed error=\(error)") +#endif + return .failure(RangeCacheStartupFailure(reason: "local-cache-server-failed")) + } + } +#else +#endif + func play() { #if canImport(MobileVLCKit) mediaPlayer.play() @@ -193,6 +315,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { func stop() { #if canImport(MobileVLCKit) playbackStartupTask?.cancel() + rangeCachePreparationTask?.cancel() + playbackStartupID = nil rangeCacheSession = nil mediaPlayer.stop() mediaPlayer.drawable = nil diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index 7c55364..f763f9f 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -43,6 +43,9 @@ struct StreamResolverTests { testRangeCacheForegroundMissFetchesAlignedChunks() await testRangeCacheForegroundMissReprioritizesPrefetch() await testRangeCacheHitFollowsActualPostSeekReadArea() + testRangeCacheStartupPolicySkipsHLSAndNonHTTPImmediately() + testRangeCacheStartupPolicyUsesCacheOnlyForConclusiveProbe() + testRangeCacheStartupPolicySkipsInconclusiveProbe() await testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges() await testRangeProbeAppliesRequestTimeout() await testRangeProbeFallsBackWhenServerIgnoresRange() @@ -542,6 +545,41 @@ struct StreamResolverTests { try? await Task.sleep(nanoseconds: 50_000_000) } + private static func testRangeCacheStartupPolicySkipsHLSAndNonHTTPImmediately() { + assertEqual( + HTTPRangeCacheStartupPolicy.immediateSkipReason(for: URL(string: "https://cdn.example.test/live.m3u8")!), + "hls-playlist" + ) + assertEqual( + HTTPRangeCacheStartupPolicy.immediateSkipReason(for: URL(string: "file:///tmp/movie.mkv")!), + "non-http-url" + ) + assertEqual( + HTTPRangeCacheStartupPolicy.immediateSkipReason(for: URL(string: "https://cdn.example.test/movie.mkv")!), + nil + ) + } + + private static func testRangeCacheStartupPolicyUsesCacheOnlyForConclusiveProbe() { + let decision = HTTPRangeCacheStartupPolicy.decision( + for: HTTPRangeProbeResult(isCacheable: true, contentLength: 20, fallbackReason: nil) + ) + + assertEqual(decision, .useLocalCache) + } + + private static func testRangeCacheStartupPolicySkipsInconclusiveProbe() { + let rejectedDecision = HTTPRangeCacheStartupPolicy.decision( + for: HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "range-probe-status-200") + ) + let missingLengthDecision = HTTPRangeCacheStartupPolicy.decision( + for: HTTPRangeProbeResult(isCacheable: true, contentLength: nil, fallbackReason: nil) + ) + + assertEqual(rejectedDecision, .skip(reason: "range-probe-status-200")) + assertEqual(missingLengthDecision, .skip(reason: "range-probe-inconclusive")) + } + private static func testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges() async { var requestCount = 0 MockURLProtocol.handler = { request in 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 5e6db74..e10ac45 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 @@ -544,6 +544,235 @@ code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.92em; ba +
+

New Changes as of May 26, 2026 at 8:42 PM EDT

+

Summary of changes

+

Changed VLC startup from a blanket direct-mode bypass to a bounded, non-blocking cache-preparation path. Dreamio now gives the local range cache a very short opportunity to prove it is safe and ready, then starts direct playback immediately if probing or server setup is slow, failed, or inconclusive.

+

Why this change was made

+

Recent MKV logs showed the previous cache probe could block native playback startup long enough for Native playback did not start before the timeout. The fix keeps native startup reliable while still allowing mode=local-cache when HTTP range support and the local server are ready inside the startup budget.

+

Code diffs

+

Dreamio/ProgressiveHTTPRangeCache.swift

Dreamio/ProgressiveHTTPRangeCache.swift
+27
57 unmodified lines
58
59
60
61
62
63
57 unmodified lines
let fallbackReason: String?
}
+
final class SparseHTTPByteRangeStore {
private struct Segment {
var range: HTTPByteRange
57 unmodified lines
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
57 unmodified lines
let fallbackReason: String?
}
+
enum HTTPRangeCacheStartupDecision: Equatable {
case useLocalCache
case skip(reason: String)
}
+
enum HTTPRangeCacheStartupPolicy {
static let preparationTimeout: TimeInterval = 0.25
static let probeTimeout: TimeInterval = 0.2
+
static func immediateSkipReason(for url: URL) -> String? {
guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {
return "non-http-url"
}
guard !url.path.lowercased().hasSuffix(".m3u8") else {
return "hls-playlist"
}
return nil
}
+
static func decision(for probe: HTTPRangeProbeResult) -> HTTPRangeCacheStartupDecision {
guard probe.isCacheable, probe.contentLength != nil else {
return .skip(reason: probe.fallbackReason ?? "range-probe-inconclusive")
}
return .useLocalCache
}
}
+
final class SparseHTTPByteRangeStore {
private struct Segment {
var range: HTTPByteRange
+

Dreamio/VLCNativePlaybackBackend.swift

Dreamio/VLCNativePlaybackBackend.swift
-3+127
24 unmodified lines
25
26
27
28
29
30
25 unmodified lines
56
57
58
59
60
61
8 unmodified lines
70
71
72
73
74
75
76
77
2 unmodified lines
80
81
82
83
84
85
86
87
88
89
103 unmodified lines
193
194
195
196
197
198
24 unmodified lines
#endif
private var rangeCacheSession: ProgressiveHTTPRangeCacheSession?
private var playbackStartupTask: Task<Void, Never>?
private var lastLoggedState: String?
private var lastBufferingLogTime: Date?
private var attachedSubtitleURLs = Set<URL>()
25 unmodified lines
func play(request: NativePlaybackRequest) {
#if canImport(MobileVLCKit)
playbackStartupTask?.cancel()
attachedSubtitleURLs.removeAll()
pendingSubtitleCandidates.removeAll()
pendingSubtitleURLs.removeAll()
8 unmodified lines
rangeCacheSession = nil
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,
2 unmodified lines
cachingMilliseconds: 2500,
includeRemoteHTTPOptions: true
)
#else
onFailure?(NativePlaybackError.backendUnavailable)
#endif
}
+
func play() {
#if canImport(MobileVLCKit)
103 unmodified lines
func stop() {
#if canImport(MobileVLCKit)
playbackStartupTask?.cancel()
rangeCacheSession = nil
mediaPlayer.stop()
mediaPlayer.drawable = nil
24 unmodified lines
25
26
27
28
29
30
31
32
25 unmodified lines
58
59
60
61
62
63
64
8 unmodified lines
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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
2 unmodified lines
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
103 unmodified lines
315
316
317
318
319
320
321
322
24 unmodified lines
#endif
private var rangeCacheSession: ProgressiveHTTPRangeCacheSession?
private var playbackStartupTask: Task<Void, Never>?
private var rangeCachePreparationTask: Task<Void, Never>?
private var playbackStartupID: UUID?
private var lastLoggedState: String?
private var lastBufferingLogTime: Date?
private var attachedSubtitleURLs = Set<URL>()
25 unmodified lines
func play(request: NativePlaybackRequest) {
#if canImport(MobileVLCKit)
playbackStartupTask?.cancel()
rangeCachePreparationTask?.cancel()
attachedSubtitleURLs.removeAll()
pendingSubtitleCandidates.removeAll()
pendingSubtitleURLs.removeAll()
8 unmodified lines
rangeCacheSession = nil
lastLoggedState = nil
lastBufferingLogTime = nil
startWithNonBlockingRangeCache(request: request)
#else
onFailure?(NativePlaybackError.backendUnavailable)
#endif
}
+
#if canImport(MobileVLCKit)
private func startWithNonBlockingRangeCache(request: NativePlaybackRequest) {
if let skipReason = HTTPRangeCacheStartupPolicy.immediateSkipReason(for: request.playbackURL) {
#if DEBUG
print("[DreamioVLC] cache skipped reason=\(skipReason) url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
#endif
startDirectPlayback(request: request, fallbackReason: skipReason)
return
}
+
let startupID = UUID()
playbackStartupID = startupID
+
playbackStartupTask = Task { [weak self] in
guard let self else {
return
}
do {
let timeoutNanoseconds = UInt64(HTTPRangeCacheStartupPolicy.preparationTimeout * 1_000_000_000)
try await Task.sleep(nanoseconds: timeoutNanoseconds)
} catch {
return
}
await MainActor.run {
guard self.canStartPlayback(for: startupID) else {
return
}
#if DEBUG
print("[DreamioVLC] cache probe timed out timeoutMs=\(Int(HTTPRangeCacheStartupPolicy.preparationTimeout * 1000)) url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
#endif
self.rangeCachePreparationTask?.cancel()
self.startDirectPlayback(request: request, fallbackReason: "startup-direct-preferred")
}
}
+
rangeCachePreparationTask = Task { [weak self] in
guard let self else {
return
}
let result = await self.prepareRangeCache(for: request)
guard !Task.isCancelled else {
return
}
await MainActor.run {
guard self.canStartPlayback(for: startupID) else {
return
}
self.playbackStartupTask?.cancel()
switch result {
case .success(let prepared):
#if DEBUG
print("[DreamioVLC] cache used mode=local-cache url=\(URLRedactor.redactedURLString(prepared.localURL.absoluteString))")
#endif
self.rangeCacheSession = prepared.session
self.startVLCMedia(
url: prepared.localURL,
request: request,
playbackMode: "local-cache",
cachingMilliseconds: 1000,
includeRemoteHTTPOptions: false
)
case .failure(let failure):
self.startDirectPlayback(request: request, fallbackReason: failure.reason)
}
}
}
}
+
private func canStartPlayback(for startupID: UUID) -> Bool {
playbackStartupID == startupID && !hasStartedMedia
}
+
private struct RangeCacheStartupFailure: Error {
let reason: String
}
+
private func startDirectPlayback(request: NativePlaybackRequest, fallbackReason: String) {
#if DEBUG
print("[DreamioVLC] direct fallback started reason=\(fallbackReason) url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
#endif
startVLCMedia(
url: request.playbackURL,
2 unmodified lines
cachingMilliseconds: 2500,
includeRemoteHTTPOptions: true
)
}
+
private struct PreparedRangeCache {
let session: ProgressiveHTTPRangeCacheSession
let localURL: URL
}
+
private func prepareRangeCache(for request: NativePlaybackRequest) async -> Result<PreparedRangeCache, RangeCacheStartupFailure> {
let fetcher = HTTPRangeRemoteFetcher(url: request.playbackURL, headers: request.headers)
let probe = await fetcher.probe(timeoutInterval: HTTPRangeCacheStartupPolicy.probeTimeout)
switch HTTPRangeCacheStartupPolicy.decision(for: probe) {
case .skip(let reason):
#if DEBUG
print("[DreamioVLC] cache skipped reason=\(reason) url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
#endif
return .failure(RangeCacheStartupFailure(reason: reason))
case .useLocalCache:
break
}
+
guard let contentLength = probe.contentLength else {
return .failure(RangeCacheStartupFailure(reason: "range-probe-inconclusive"))
}
let session = ProgressiveHTTPRangeCacheSession(
fetcher: fetcher,
contentLength: contentLength,
durationProvider: { [weak self] in self?.duration ?? 0 }
)
do {
let localURL = try await ProgressiveHTTPRangeCacheServer.shared.localURL(for: session)
return .success(PreparedRangeCache(session: session, localURL: localURL))
} catch {
#if DEBUG
print("[DreamioVLC] local cache server failed error=\(error)")
#endif
return .failure(RangeCacheStartupFailure(reason: "local-cache-server-failed"))
}
}
#else
#endif
+
func play() {
#if canImport(MobileVLCKit)
103 unmodified lines
func stop() {
#if canImport(MobileVLCKit)
playbackStartupTask?.cancel()
rangeCachePreparationTask?.cancel()
playbackStartupID = nil
rangeCacheSession = nil
mediaPlayer.stop()
mediaPlayer.drawable = nil
+

Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+38
42 unmodified lines
43
44
45
46
47
48
493 unmodified lines
542
543
544
545
546
547
42 unmodified lines
testRangeCacheForegroundMissFetchesAlignedChunks()
await testRangeCacheForegroundMissReprioritizesPrefetch()
await testRangeCacheHitFollowsActualPostSeekReadArea()
await testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges()
await testRangeProbeAppliesRequestTimeout()
await testRangeProbeFallsBackWhenServerIgnoresRange()
493 unmodified lines
try? await Task.sleep(nanoseconds: 50_000_000)
}
+
private static func testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges() async {
var requestCount = 0
MockURLProtocol.handler = { request in
42 unmodified lines
43
44
45
46
47
48
49
50
51
493 unmodified lines
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
573
574
575
576
577
578
579
580
581
582
583
584
585
42 unmodified lines
testRangeCacheForegroundMissFetchesAlignedChunks()
await testRangeCacheForegroundMissReprioritizesPrefetch()
await testRangeCacheHitFollowsActualPostSeekReadArea()
testRangeCacheStartupPolicySkipsHLSAndNonHTTPImmediately()
testRangeCacheStartupPolicyUsesCacheOnlyForConclusiveProbe()
testRangeCacheStartupPolicySkipsInconclusiveProbe()
await testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges()
await testRangeProbeAppliesRequestTimeout()
await testRangeProbeFallsBackWhenServerIgnoresRange()
493 unmodified lines
try? await Task.sleep(nanoseconds: 50_000_000)
}
+
private static func testRangeCacheStartupPolicySkipsHLSAndNonHTTPImmediately() {
assertEqual(
HTTPRangeCacheStartupPolicy.immediateSkipReason(for: URL(string: "https://cdn.example.test/live.m3u8")!),
"hls-playlist"
)
assertEqual(
HTTPRangeCacheStartupPolicy.immediateSkipReason(for: URL(string: "file:///tmp/movie.mkv")!),
"non-http-url"
)
assertEqual(
HTTPRangeCacheStartupPolicy.immediateSkipReason(for: URL(string: "https://cdn.example.test/movie.mkv")!),
nil
)
}
+
private static func testRangeCacheStartupPolicyUsesCacheOnlyForConclusiveProbe() {
let decision = HTTPRangeCacheStartupPolicy.decision(
for: HTTPRangeProbeResult(isCacheable: true, contentLength: 20, fallbackReason: nil)
)
+
assertEqual(decision, .useLocalCache)
}
+
private static func testRangeCacheStartupPolicySkipsInconclusiveProbe() {
let rejectedDecision = HTTPRangeCacheStartupPolicy.decision(
for: HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "range-probe-status-200")
)
let missingLengthDecision = HTTPRangeCacheStartupPolicy.decision(
for: HTTPRangeProbeResult(isCacheable: true, contentLength: nil, fallbackReason: nil)
)
+
assertEqual(rejectedDecision, .skip(reason: "range-probe-status-200"))
assertEqual(missingLengthDecision, .skip(reason: "range-probe-inconclusive"))
}
+
private static func testRangeProbeAllowsRangeCacheForMKVWhenServerSupportsRanges() async {
var requestCount = 0
MockURLProtocol.handler = { request in
+ +

Related issues or PRs

+
    +
  • Beads issue: dreamio-5cz.
  • +
  • No pull request was opened in this turn.
  • +
+
+