diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index ce656c0..22e71d0 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -41,3 +41,4 @@ {"id":"int-c15b9cb9","kind":"field_change","created_at":"2026-05-25T19:07:23.629637Z","actor":"dirtydishes","issue_id":"dreamio-3yb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added centralized 30-second VLC media caching for native playback and validated the iOS build."}} {"id":"int-e339ed64","kind":"field_change","created_at":"2026-05-25T20:22:40.999137Z","actor":"dirtydishes","issue_id":"dreamio-dsp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"implemented local native stream cache proxy with range cache tests and successful simulator build"}} {"id":"int-79713eba","kind":"field_change","created_at":"2026-05-25T21:55:32.323229Z","actor":"dirtydishes","issue_id":"dreamio-6bv","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"handled VLC buffering follow-up by supporting HEAD probes, moving fetch work off listener queue, reducing foreground range size, and locking cache access"}} +{"id":"int-b2667330","kind":"field_change","created_at":"2026-05-25T23:44:07.439593Z","actor":"dirtydishes","issue_id":"dreamio-9gw","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Capped local range-cache responses to 1 MB chunks, trimmed cached overlap windows, added focused tests, and confirmed the iOS simulator build succeeds."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index eaee3c3..808b3f4 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,13 @@ {"_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-9gw","title":"Cap VLC local range cache memory","description":"Playback can be killed for memory when VLC asks the loopback cache for a very large byte range. The local range cache should answer with bounded partial ranges and trim cached segments to the active window.","status":"closed","priority":1,"issue_type":"bug","owner":"dishes@dpdrm.com","created_at":"2026-05-25T23:38:08Z","created_by":"dirtydishes","updated_at":"2026-05-25T23:44:07Z","closed_at":"2026-05-25T23:44:07Z","close_reason":"Capped local range-cache responses to 1 MB chunks, trimmed cached overlap windows, added focused tests, and confirmed the iOS simulator build succeeds.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-4t0","title":"Fix native external subtitle overlay fallback","description":"Parsed external subtitles are discovered but MobileVLCKit may report no imported subtitle tracks. Make Dreamio's parsed subtitle overlay the reliable fallback and add parser/overlay coverage.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T23:23:15Z","created_by":"dirtydishes","updated_at":"2026-05-25T23:28:44Z","started_at":"2026-05-25T23:23:18Z","closed_at":"2026-05-25T23:28:44Z","close_reason":"Implemented parsed external subtitle overlay fallback, parser extraction, focused parser tests, and simulator build validation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-8l9","title":"Fix native external subtitle overlay fallback","description":"External subtitles are parsed and listed, but MobileVLCKit can report no imported subtitle tracks. Make parsed external subtitles the reliable overlay fallback, keep VLC import attempts optional, and add focused parser/cue tests.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T23:13:35Z","created_by":"dirtydishes","updated_at":"2026-05-25T23:17:40Z","started_at":"2026-05-25T23:13:49Z","closed_at":"2026-05-25T23:17:40Z","close_reason":"Implemented native parsed external subtitle overlay fallback, added SRT parser/cue tests, and validated with parser tests plus iOS Simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-7wi","title":"render external subtitles outside vlc","description":"MobileVLCKit does not expose local external subtitle files even when they are added before playback. Add a native subtitle overlay fallback that parses resolved local subtitle files and renders cues against backend playback time.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T23:08:27Z","created_by":"dirtydishes","updated_at":"2026-05-25T23:10:10Z","started_at":"2026-05-25T23:08:29Z","closed_at":"2026-05-25T23:10:10Z","close_reason":"Added a native subtitle overlay fallback that parses resolved local subtitle files and renders active cues when VLC exposes no subtitle tracks.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-3aq","title":"add queued subtitles before vlc playback","description":"MobileVLCKit accepts queued local subtitle files with addPlaybackSlave after playback starts but does not expose subtitle tracks. Add queued subtitle files to the VLCMedia before play using input-slave options, reserving addPlaybackSlave for late arrivals.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T23:04:34Z","created_by":"dirtydishes","updated_at":"2026-05-25T23:05:33Z","started_at":"2026-05-25T23:04:36Z","closed_at":"2026-05-25T23:05:33Z","close_reason":"Moved queued local subtitle files onto the VLCMedia as input-slave options before playback starts, leaving addPlaybackSlave for late subtitles only.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-8oe","title":"accept plain stremio subtitle text","description":"Stremio subtitle download URLs return text that does not match the strict SRT/WebVTT/ASS detector, so the resolver rejects every candidate before VLC can attach subtitles. Accept non-markup text bodies from Stremio subtitle downloads as local subtitle files and improve rejection diagnostics.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:58:45Z","created_by":"dirtydishes","updated_at":"2026-05-25T23:00:33Z","started_at":"2026-05-25T22:58:50Z","closed_at":"2026-05-25T23:00:33Z","close_reason":"Accepted plausible plain text from Stremio subtitle downloads as cached local SRT files and added rejection previews for any remaining misses.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-771","title":"cache stremio subtitles before VLC attach","description":"VLC accepts Stremio subtitle download URLs but does not expose external tracks from those extensionless provider URLs. Download resolvable subtitle payloads to local files with subtitle extensions before attachment so VLC can import them.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:53:43Z","created_by":"dirtydishes","updated_at":"2026-05-25T22:55:39Z","started_at":"2026-05-25T22:53:45Z","closed_at":"2026-05-25T22:55:39Z","close_reason":"Resolved Stremio subtitle download URLs to local cached subtitle files before VLC attachment so extensionless provider URLs no longer go directly to MobileVLCKit.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-qyh","title":"queue subtitles until VLC media starts","description":"Buffered subtitle candidates can arrive before VLC has a media item when local range-cache setup performs an async probe. Queue those candidates and attach them once VLC media has been created so external subtitle tracks materialize.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:37:49Z","created_by":"dirtydishes","updated_at":"2026-05-25T22:41:09Z","started_at":"2026-05-25T22:37:52Z","closed_at":"2026-05-25T22:41:09Z","close_reason":"Queued subtitle candidates until VLC media startup completes so local-cache playback no longer attaches external subtitles before a media item exists.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-11s","title":"add progressive vlc range cache","description":"Implement Dreamio-owned progressive HTTP range cache for native VLC playback, including local loopback playback, diagnostics, and range cache tests.","status":"closed","priority":1,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:20:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T22:33:59Z","closed_at":"2026-05-25T22:33:59Z","close_reason":"Implemented progressive HTTP range cache, VLC loopback playback selection, diagnostics, and cache tests.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-6bv","title":"fix native stream proxy buffering after seek","description":"Investigate and fix VLC staying in buffering after native proxy-backed jump seeks. Logs show time remains pinned after jump while state repeatedly reports buffering.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T21:53:57Z","created_by":"dirtydishes","updated_at":"2026-05-25T21:55:32Z","started_at":"2026-05-25T21:54:01Z","closed_at":"2026-05-25T21:55:32Z","close_reason":"handled VLC buffering follow-up by supporting HEAD probes, moving fetch work off listener queue, reducing foreground range size, and locking cache access","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-dsp","title":"add local seek buffer proxy for native playback","description":"Implement a local HTTP range proxy/cache between VLC and direct-file streams so nearby seeks can use retained bytes, while preserving stream headers and subtitle behavior.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T20:18:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T20:22:41Z","started_at":"2026-05-25T20:18:47Z","closed_at":"2026-05-25T20:22:41Z","close_reason":"implemented local native stream cache proxy with range cache tests and successful simulator build","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/ProgressiveHTTPRangeCache.swift b/Dreamio/ProgressiveHTTPRangeCache.swift index aa8df4a..ad514ed 100644 --- a/Dreamio/ProgressiveHTTPRangeCache.swift +++ b/Dreamio/ProgressiveHTTPRangeCache.swift @@ -115,7 +115,19 @@ final class SparseHTTPByteRangeStore { func evict(keeping window: HTTPByteRange) { lock.withLock { - segments.removeAll { !$0.range.overlapsOrTouches(window) } + segments = segments.compactMap { segment in + guard segment.range.overlapsOrTouches(window) else { + return nil + } + let start = max(segment.range.start, window.start) + let end = min(segment.range.end, window.end) + guard start <= end else { + return nil + } + let lower = Int(start - segment.range.start) + let upper = Int(end - segment.range.start + 1) + return Segment(range: HTTPByteRange(start: start, end: end), data: segment.data.subdata(in: lower.. TimeInterval private let prefetchChunkSize: Int64 = 1_048_576 + private let responseChunkSize: Int64 = 1_048_576 private var prefetchTask: Task? init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) { @@ -266,6 +279,14 @@ final class ProgressiveHTTPRangeCacheSession { return store.data(for: bounded) ?? data } + func responseRange(for requestedRange: HTTPByteRange) -> HTTPByteRange { + let bounded = clamp(requestedRange) + return HTTPByteRange( + start: bounded.start, + end: min(bounded.end, bounded.start + responseChunkSize - 1) + ) + } + func prefetch(aroundByteOffset offset: Int64) { prefetchTask?.cancel() let window = targetWindow(aroundByteOffset: offset) @@ -446,12 +467,13 @@ final class ProgressiveHTTPRangeCacheServer { let requestedRange = parseRangeHeader(in: requestText, contentLength: session.contentLength) ?? HTTPByteRange(start: 0, end: min(session.contentLength - 1, 1_048_575)) + let responseRange = session.responseRange(for: requestedRange) do { - let data = try await session.data(for: requestedRange) + let data = try await session.data(for: responseRange) let headers = [ "Accept-Ranges": "bytes", "Content-Length": "\(data.count)", - "Content-Range": "bytes \(requestedRange.start)-\(requestedRange.end)/\(session.contentLength)", + "Content-Range": "bytes \(responseRange.start)-\(responseRange.end)/\(session.contentLength)", "Content-Type": "application/octet-stream", "Connection": "close" ] diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index d27b863..ec7038d 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -34,6 +34,8 @@ struct StreamResolverTests { testSparseRangeStoreMergesOverlaps() testSparseRangeStoreHitPartialHitAndMiss() testSparseRangeStoreEvictsOutsideWindow() + testSparseRangeStoreTrimsOverlappingWindow() + testRangeCacheSessionCapsResponseRange() await testRangeProbeFallsBackWhenServerIgnoresRange() await testRangeFetcherPreservesHeaders() print("StreamResolverTests passed") @@ -319,6 +321,29 @@ struct StreamResolverTests { assertEqual(store.cachedRanges, [HTTPByteRange(start: 10, end: 12)]) } + private static func testSparseRangeStoreTrimsOverlappingWindow() { + let store = SparseHTTPByteRangeStore() + + store.insert(data: Data([0, 1, 2, 3, 4, 5]), at: 0) + store.evict(keeping: HTTPByteRange(start: 2, end: 4)) + + assertEqual(store.cachedRanges, [HTTPByteRange(start: 2, end: 4)]) + assertEqual(Array(store.data(for: HTTPByteRange(start: 2, end: 4)) ?? Data()), [2, 3, 4]) + assert(store.data(for: HTTPByteRange(start: 0, end: 5)) == nil, "Expected trimmed bytes outside the window to be evicted") + } + + private static func testRangeCacheSessionCapsResponseRange() { + let session = ProgressiveHTTPRangeCacheSession( + fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]), + contentLength: 711_080_522, + durationProvider: { 0 } + ) + + let responseRange = session.responseRange(for: HTTPByteRange(start: 0, end: 711_080_521)) + + assertEqual(responseRange, HTTPByteRange(start: 0, end: 1_048_575)) + } + private static func testRangeProbeFallsBackWhenServerIgnoresRange() async { MockURLProtocol.handler = { request in if request.httpMethod == "HEAD" { diff --git a/docs/turns/2026-05-25-vlc-local-range-cache.html b/docs/turns/2026-05-25-vlc-local-range-cache.html index 29c5136..34a1db7 100644 --- a/docs/turns/2026-05-25-vlc-local-range-cache.html +++ b/docs/turns/2026-05-25-vlc-local-range-cache.html @@ -299,6 +299,134 @@ port = UInt16(listener.port.rawValue) startup continuations resume only after the real port is available.

Related issues or PRs

Related to Beads issue dreamio-11s and branch lavender/vlc-local-range-cache.

+

New Changes as of 2026-05-25 19:43 EDT

Summary of changes

Capped the VLC loopback range cache so an oversized player request like bytes=0-711080521 is answered as a small partial response instead of being fetched and stored as one huge in-memory Data value.

Why this change was made

The device logs showed VLC requesting almost the entire file from the local cache on startup. The cache honored that range literally, which could allocate hundreds of megabytes for the upstream response and then keep another copy in the sparse cache. That explains the memory kill during playback.

Code diffs

Rendered with @pierre/diffs/ssr. These are focused snippets for the memory cap and its tests.

Dreamio/ProgressiveHTTPRangeCache.swift and Tests/StreamResolverTests.swift

Dreamio/ProgressiveHTTPRangeCache.swift
-3+21
114 unmodified lines
115
116
117
118
119
144 unmodified lines
266
267
174 unmodified lines
446
447
448
449
450
451
452
453
454
114 unmodified lines
func evict(keeping window: HTTPByteRange) {
lock.withLock {
segments.removeAll { !$0.range.overlapsOrTouches(window) }
}
}
144 unmodified lines
return store.data(for: bounded) ?? data
}
174 unmodified lines
let requestedRange = parseRangeHeader(in: requestText, contentLength: session.contentLength)
?? HTTPByteRange(start: 0, end: min(session.contentLength - 1, 1_048_575))
do {
let data = try await session.data(for: requestedRange)
let headers = [
"Accept-Ranges": "bytes",
"Content-Length": "\(data.count)",
"Content-Range": "bytes \(requestedRange.start)-\(requestedRange.end)/\(session.contentLength)",
"Content-Type": "application/octet-stream",
114 unmodified lines
115
116
117
118
119
120
121
122
123
124
125
126
127
128
144 unmodified lines
278
279
280
281
282
283
284
285
286
287
174 unmodified lines
466
467
468
469
470
471
472
473
474
475
114 unmodified lines
func evict(keeping window: HTTPByteRange) {
lock.withLock {
segments = segments.compactMap { segment in
guard segment.range.overlapsOrTouches(window) else {
return nil
}
let start = max(segment.range.start, window.start)
let end = min(segment.range.end, window.end)
let lower = Int(start - segment.range.start)
let upper = Int(end - segment.range.start + 1)
return Segment(range: HTTPByteRange(start: start, end: end), data: segment.data.subdata(in: lower..<upper))
}
}
}
144 unmodified lines
return store.data(for: bounded) ?? data
}
+
func responseRange(for requestedRange: HTTPByteRange) -> HTTPByteRange {
let bounded = clamp(requestedRange)
return HTTPByteRange(
start: bounded.start,
end: min(bounded.end, bounded.start + responseChunkSize - 1)
)
}
174 unmodified lines
let requestedRange = parseRangeHeader(in: requestText, contentLength: session.contentLength)
?? HTTPByteRange(start: 0, end: min(session.contentLength - 1, 1_048_575))
let responseRange = session.responseRange(for: requestedRange)
do {
let data = try await session.data(for: responseRange)
let headers = [
"Accept-Ranges": "bytes",
"Content-Length": "\(data.count)",
"Content-Range": "bytes \(responseRange.start)-\(responseRange.end)/\(session.contentLength)",
"Content-Type": "application/octet-stream",
Tests/StreamResolverTests.swift
+12
320 unmodified lines
319
320
320 unmodified lines
assertEqual(store.cachedRanges, [HTTPByteRange(start: 10, end: 12)])
}
320 unmodified lines
321
322
323
324
325
326
327
328
329
330
331
332
333
334
320 unmodified lines
assertEqual(store.cachedRanges, [HTTPByteRange(start: 10, end: 12)])
}
+
private static func testSparseRangeStoreTrimsOverlappingWindow() {
let store = SparseHTTPByteRangeStore()
store.insert(data: Data([0, 1, 2, 3, 4, 5]), at: 0)
store.evict(keeping: HTTPByteRange(start: 2, end: 4))
assertEqual(store.cachedRanges, [HTTPByteRange(start: 2, end: 4)])
}
+
private static func testRangeCacheSessionCapsResponseRange() {
let session = ProgressiveHTTPRangeCacheSession(fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]), contentLength: 711_080_522, durationProvider: { 0 })
assertEqual(session.responseRange(for: HTTPByteRange(start: 0, end: 711_080_521)), HTTPByteRange(start: 0, end: 1_048_575))
}

Related issues or PRs

Related to Beads issue dreamio-9gw and branch lavender/vlc-local-range-cache.