reduce seek buffering in range cache

This commit is contained in:
dirtydishes 2026-05-26 00:00:57 -04:00
parent 73eeb09eda
commit 5cd5d2f9ff
5 changed files with 220 additions and 10 deletions

View file

@ -42,3 +42,4 @@
{"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-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-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."}} {"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."}}
{"id":"int-6ca684f7","kind":"field_change","created_at":"2026-05-26T04:00:46.072019Z","actor":"dirtydishes","issue_id":"dreamio-42s","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed seek-time range-cache prefetching to prioritize the post-seek byte offset and avoid cancelling active prefetch work inside the same window; added focused coverage and validated with StreamResolverTests plus xcodebuild."}}

View file

@ -1,5 +1,6 @@
{"_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-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-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-42s","title":"Reduce VLC range-cache buffering after seeks","description":"Logs show repeated local-cache misses and cancelled prefetch tasks after VLC jumps backward, causing buffering while the cache restarts speculative requests instead of preserving useful adjacent downloads.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T03:58:03Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:00:46Z","started_at":"2026-05-26T03:58:10Z","closed_at":"2026-05-26T04:00:46Z","close_reason":"Fixed seek-time range-cache prefetching to prioritize the post-seek byte offset and avoid cancelling active prefetch work inside the same window; added focused coverage and validated with StreamResolverTests plus xcodebuild.","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-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-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-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}

View file

@ -16,6 +16,10 @@ struct HTTPByteRange: Equatable {
func merged(with other: HTTPByteRange) -> HTTPByteRange { func merged(with other: HTTPByteRange) -> HTTPByteRange {
HTTPByteRange(start: min(start, other.start), end: max(end, other.end)) HTTPByteRange(start: min(start, other.start), end: max(end, other.end))
} }
func contains(_ offset: Int64) -> Bool {
start <= offset && offset <= end
}
} }
struct HTTPContentRange: Equatable { struct HTTPContentRange: Equatable {
@ -254,6 +258,7 @@ final class ProgressiveHTTPRangeCacheSession {
private let prefetchChunkSize: Int64 = 1_048_576 private let prefetchChunkSize: Int64 = 1_048_576
private let responseChunkSize: Int64 = 1_048_576 private let responseChunkSize: Int64 = 1_048_576
private var prefetchTask: Task<Void, Never>? private var prefetchTask: Task<Void, Never>?
private var activePrefetchWindow: HTTPByteRange?
init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) { init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {
self.fetcher = fetcher self.fetcher = fetcher
@ -288,10 +293,16 @@ final class ProgressiveHTTPRangeCacheSession {
} }
func prefetch(aroundByteOffset offset: Int64) { func prefetch(aroundByteOffset offset: Int64) {
if activePrefetchWindow?.contains(offset) == true, prefetchTask?.isCancelled == false {
return
}
prefetchTask?.cancel() prefetchTask?.cancel()
let window = targetWindow(aroundByteOffset: offset) let window = targetWindow(aroundByteOffset: offset)
activePrefetchWindow = window
store.evict(keeping: window) store.evict(keeping: window)
guard !store.hasData(for: window) else { guard !store.hasData(for: window) else {
activePrefetchWindow = nil
return return
} }
@ -299,9 +310,10 @@ final class ProgressiveHTTPRangeCacheSession {
guard let self else { guard let self else {
return return
} }
var cursor = window.start for chunk in self.prefetchChunks(in: window, preferredOffset: offset) {
while cursor <= window.end, !Task.isCancelled { guard !Task.isCancelled else {
let chunk = HTTPByteRange(start: cursor, end: min(window.end, cursor + prefetchChunkSize - 1)) return
}
if !store.hasData(for: chunk) { if !store.hasData(for: chunk) {
do { do {
let data = try await fetcher.fetch(range: chunk) let data = try await fetcher.fetch(range: chunk)
@ -310,14 +322,17 @@ final class ProgressiveHTTPRangeCacheSession {
print("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)") print("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)")
#endif #endif
} catch { } catch {
if Task.isCancelled {
return
}
#if DEBUG #if DEBUG
print("[DreamioRangeCache] prefetch failed range=\(chunk.start)-\(chunk.end) error=\(error)") print("[DreamioRangeCache] prefetch failed range=\(chunk.start)-\(chunk.end) error=\(error)")
#endif #endif
return return
} }
} }
cursor = chunk.end + 1
} }
self.activePrefetchWindow = nil
} }
} }
@ -333,6 +348,28 @@ final class ProgressiveHTTPRangeCacheSession {
return clamp(HTTPByteRange(start: offset - behind, end: offset + ahead)) return clamp(HTTPByteRange(start: offset - behind, end: offset + ahead))
} }
func prefetchChunks(in window: HTTPByteRange, preferredOffset offset: Int64) -> [HTTPByteRange] {
let boundedOffset = max(window.start, min(window.end, offset))
let preferredStart = window.start + ((boundedOffset - window.start) / prefetchChunkSize) * prefetchChunkSize
var chunks: [HTTPByteRange] = []
var cursor = preferredStart
while cursor <= window.end {
let chunk = HTTPByteRange(start: cursor, end: min(window.end, cursor + prefetchChunkSize - 1))
chunks.append(chunk)
cursor = chunk.end + 1
}
cursor = window.start
while cursor < preferredStart {
let chunk = HTTPByteRange(start: cursor, end: min(preferredStart - 1, cursor + prefetchChunkSize - 1))
chunks.append(chunk)
cursor = chunk.end + 1
}
return chunks
}
private func estimatedBytesPerSecond() -> Int64 { private func estimatedBytesPerSecond() -> Int64 {
let duration = durationProvider() let duration = durationProvider()
guard duration > 1 else { guard duration > 1 else {

View file

@ -36,6 +36,7 @@ struct StreamResolverTests {
testSparseRangeStoreEvictsOutsideWindow() testSparseRangeStoreEvictsOutsideWindow()
testSparseRangeStoreTrimsOverlappingWindow() testSparseRangeStoreTrimsOverlappingWindow()
testRangeCacheSessionCapsResponseRange() testRangeCacheSessionCapsResponseRange()
testRangeCachePrefetchPrioritizesSeekOffset()
await testRangeProbeFallsBackWhenServerIgnoresRange() await testRangeProbeFallsBackWhenServerIgnoresRange()
await testRangeFetcherPreservesHeaders() await testRangeFetcherPreservesHeaders()
print("StreamResolverTests passed") print("StreamResolverTests passed")
@ -344,6 +345,28 @@ struct StreamResolverTests {
assertEqual(responseRange, HTTPByteRange(start: 0, end: 1_048_575)) assertEqual(responseRange, HTTPByteRange(start: 0, end: 1_048_575))
} }
private static func testRangeCachePrefetchPrioritizesSeekOffset() {
let session = ProgressiveHTTPRangeCacheSession(
fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),
contentLength: 20_000_000,
durationProvider: { 0 }
)
let chunks = session.prefetchChunks(
in: HTTPByteRange(start: 0, end: 4_194_303),
preferredOffset: 2_200_000
)
assertEqual(chunks.prefix(2).map { $0 }, [
HTTPByteRange(start: 2_097_152, end: 3_145_727),
HTTPByteRange(start: 3_145_728, end: 4_194_303)
])
assertEqual(chunks.suffix(2).map { $0 }, [
HTTPByteRange(start: 0, end: 1_048_575),
HTTPByteRange(start: 1_048_576, end: 2_097_151)
])
}
private static func testRangeProbeFallsBackWhenServerIgnoresRange() async { private static func testRangeProbeFallsBackWhenServerIgnoresRange() async {
MockURLProtocol.handler = { request in MockURLProtocol.handler = { request in
if request.httpMethod == "HEAD" { if request.httpMethod == "HEAD" {

File diff suppressed because one or more lines are too long