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/AGENTS.md b/AGENTS.md index cd1e980..334aa68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,6 +133,8 @@ If a change does not cleanly fit either exempt or substantive buckets, ask the u Use the `impeccable` skill to structure and style the document as clean, readable HTML. For this repository, `impeccable` is the styling and layout authority for turn documents when available. Do not apply global non-repo computer-task house styling to repository turn documents. +Future turn documents must start from `docs/turns/template.html`. The template is the canonical appearance baseline for this repository: dark polished layout, lavender and pink accent colors, compact header metadata, clear section rhythm, and contained diff shells modeled after `/Users/kell/dev/islandflow/docs/turns/2026-05-20-fix-alert-flow-packet-history.html`. + If `impeccable` is unavailable or blocked by an actual tool/file error, still create a well-structured standalone HTML file. Each turn document must include these sections: @@ -157,7 +159,7 @@ For a minor update to a previous substantive change, add this section to the exi ### Rendered Diff Documentation -When turn documentation needs rendered code diffs, use `@pierre/diffs` through its ESM server-side renderer. +When turn documentation needs rendered code diffs, use Clean SSR with `@pierre/diffs` through its ESM server-side renderer. The final HTML must be readable as a static file and must not depend on client-side package loading. Use `@pierre/diffs/ssr` with Node ESM imports. Do not test, load, or diagnose this package with CommonJS `require()`, because `@pierre/diffs` is ESM and `require('@pierre/diffs/ssr')` can falsely look like an export or package failure. @@ -188,7 +190,16 @@ NODE Do not run `npx @pierre/diffs`; the package is a rendering library and does not expose a CLI executable. -Only use a clearly labeled plain diff or code-block fallback when the ESM import-and-render pattern above fails because of a real tool, install, or runtime error. Document the failure briefly in the turn document. +Diff output must follow these readability rules: + +- Use curated, relevant snippets rather than dumping an entire commit or full-file diff when a focused snippet explains the change. +- Render one file diff per `.diff-shell`, with a clear `.diff-title` naming the file and purpose. +- Insert generated SSR HTML only inside the matching `.diff-view` element from `docs/turns/template.html`. +- Never paste generated SSR output as a freestanding section body or outside the diff shell. +- Keep normal prose sections around the generated markup so the source and rendered page remain navigable. +- Mark a shell with `class="diff-shell rendered"` when SSR output is present so the fallback is hidden. + +Only use a clearly labeled plain diff or code-block fallback in the template's `
` block when the ESM import-and-render pattern above fails because of a real tool, install, or runtime error. Document the failure briefly in the turn document.
## Plan Mode Documentation
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+2757 unmodified lines58596061626357 unmodified lines let fallbackReason: String?}
+final class SparseHTTPByteRangeStore { private struct Segment { var range: HTTPByteRange57 unmodified lines58596061626364656667686970717273747576777879808182838485868788899057 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+12724 unmodified lines25262728293025 unmodified lines5657585960618 unmodified lines70717273747576772 unmodified lines80818283848586878889103 unmodified lines19319419519619719824 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 = nil24 unmodified lines252627282930313225 unmodified lines585960616263648 unmodified lines7374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621632 unmodified lines166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211103 unmodified lines31531631731831932032132224 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+3842 unmodified lines434445464748493 unmodified lines54254354454554654742 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 in42 unmodified lines434445464748495051493 unmodified lines54554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458542 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.
+
+
+