follow vlc foreground reads after seeks

This commit is contained in:
dirtydishes 2026-05-26 00:40:23 -04:00
parent 365dfb22f9
commit 0f3101ce5c
5 changed files with 239 additions and 3 deletions

View file

@ -45,3 +45,4 @@
{"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."}} {"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."}}
{"id":"int-176e3ad2","kind":"field_change","created_at":"2026-05-26T04:14:19.812849Z","actor":"dirtydishes","issue_id":"dreamio-meh","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed range-cache prefetch reprioritization so foreground VLC misses cancel stale speculative work and restart around VLC's actual requested byte range; added regression coverage for the observed jump mismatch."}} {"id":"int-176e3ad2","kind":"field_change","created_at":"2026-05-26T04:14:19.812849Z","actor":"dirtydishes","issue_id":"dreamio-meh","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed range-cache prefetch reprioritization so foreground VLC misses cancel stale speculative work and restart around VLC's actual requested byte range; added regression coverage for the observed jump mismatch."}}
{"id":"int-56a87fde","kind":"field_change","created_at":"2026-05-26T04:35:35.693504Z","actor":"dirtydishes","issue_id":"dreamio-3pn","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented backward-biased seek priming, global 1 MB range-cache chunk alignment, bounded protected eviction, partial foreground miss fetching/logging, main-actor VLC delegate handling, tests, and turn documentation."}} {"id":"int-56a87fde","kind":"field_change","created_at":"2026-05-26T04:35:35.693504Z","actor":"dirtydishes","issue_id":"dreamio-3pn","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented backward-biased seek priming, global 1 MB range-cache chunk alignment, bounded protected eviction, partial foreground miss fetching/logging, main-actor VLC delegate handling, tests, and turn documentation."}}
{"id":"int-91b3db21","kind":"field_change","created_at":"2026-05-26T04:40:10.299245Z","actor":"dirtydishes","issue_id":"dreamio-mi1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Used actual foreground VLC reads as prefetch follow signals on hits and changed foreground misses to fetch aligned chunks; added regression tests and updated the turn document."}}

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-mi1","title":"adapt vlc prefetch to actual post-seek reads","description":"Use real foreground VLC reads after a seek as a prefetch signal even when they are cache hits, and fetch aligned chunks for partial foreground misses so the cache warms ahead before VLC reaches the edge of retained data.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T04:38:14Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:40:10Z","started_at":"2026-05-26T04:38:16Z","closed_at":"2026-05-26T04:40:10Z","close_reason":"Used actual foreground VLC reads as prefetch follow signals on hits and changed foreground misses to fetch aligned chunks; added regression tests and updated the turn document.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-3pn","title":"reduce vlc seek buffering with range cache priming","description":"Improve VLC local range cache behavior after seek/jump by priming bytes behind the target, using stable global chunk boundaries, retaining useful cached ranges under a byte budget, and adding tests for the observed post-seek request pattern.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T04:31:46Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:35:36Z","started_at":"2026-05-26T04:31:51Z","closed_at":"2026-05-26T04:35:36Z","close_reason":"Implemented backward-biased seek priming, global 1 MB range-cache chunk alignment, bounded protected eviction, partial foreground miss fetching/logging, main-actor VLC delegate handling, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-3pn","title":"reduce vlc seek buffering with range cache priming","description":"Improve VLC local range cache behavior after seek/jump by priming bytes behind the target, using stable global chunk boundaries, retaining useful cached ranges under a byte budget, and adding tests for the observed post-seek request pattern.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T04:31:46Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:35:36Z","started_at":"2026-05-26T04:31:51Z","closed_at":"2026-05-26T04:35:36Z","close_reason":"Implemented backward-biased seek priming, global 1 MB range-cache chunk alignment, bounded protected eviction, partial foreground miss fetching/logging, main-actor VLC delegate handling, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-meh","title":"Use VLC range requests to reprioritize seek prefetch","description":"Jump logs show duration-based byteOffset estimates can be far behind VLC's actual post-seek range requests, so prefetch keeps warming stale bytes while VLC buffers on higher cache misses.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T04:11:38Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:14:20Z","started_at":"2026-05-26T04:11:40Z","closed_at":"2026-05-26T04:14:20Z","close_reason":"Fixed range-cache prefetch reprioritization so foreground VLC misses cancel stale speculative work and restart around VLC's actual requested byte range; added regression coverage for the observed jump mismatch.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-meh","title":"Use VLC range requests to reprioritize seek prefetch","description":"Jump logs show duration-based byteOffset estimates can be far behind VLC's actual post-seek range requests, so prefetch keeps warming stale bytes while VLC buffers on higher cache misses.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T04:11:38Z","created_by":"dirtydishes","updated_at":"2026-05-26T04:14:20Z","started_at":"2026-05-26T04:11:40Z","closed_at":"2026-05-26T04:14:20Z","close_reason":"Fixed range-cache prefetch reprioritization so foreground VLC misses cancel stale speculative work and restart around VLC's actual requested byte range; added regression coverage for the observed jump mismatch.","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-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}

View file

@ -376,6 +376,7 @@ final class ProgressiveHTTPRangeCacheSession {
#if DEBUG #if DEBUG
print("[DreamioRangeCache] cache=hit range=\(bounded.start)-\(bounded.end)") print("[DreamioRangeCache] cache=hit range=\(bounded.start)-\(bounded.end)")
#endif #endif
prefetchAheadIfForegroundMoved(to: bounded)
return data return data
} }
@ -386,8 +387,13 @@ final class ProgressiveHTTPRangeCacheSession {
#endif #endif
cancelPrefetchIfNeeded(forForegroundRange: bounded) cancelPrefetchIfNeeded(forForegroundRange: bounded)
for missingRange in missingRanges { for missingRange in missingRanges {
let data = try await fetcher.fetch(range: missingRange) for fetchRange in alignedChunks(covering: missingRange) where !store.hasData(for: fetchRange) {
store.insert(data: data, at: missingRange.start) let data = try await fetcher.fetch(range: fetchRange)
store.insert(data: data, at: fetchRange.start)
#if DEBUG
print("[DreamioRangeCache] foreground fetched range=\(fetchRange.start)-\(fetchRange.end) bytes=\(data.count)")
#endif
}
} }
prefetch(aroundByteOffset: bounded.end + 1, forceRestart: true) prefetch(aroundByteOffset: bounded.end + 1, forceRestart: true)
return store.data(for: bounded) ?? Data() return store.data(for: bounded) ?? Data()
@ -500,6 +506,18 @@ final class ProgressiveHTTPRangeCacheSession {
cancelPrefetch() cancelPrefetch()
} }
private func prefetchAheadIfForegroundMoved(to range: HTTPByteRange) {
guard activePrefetchWindow?.contains(range.start) == true,
let preferredOffset = activePrefetchPreferredOffset,
abs(range.start - preferredOffset) >= responseChunkSize else {
return
}
#if DEBUG
print("[DreamioRangeCache] prefetch follow-foreground from=\(preferredOffset) to=\(range.end + 1)")
#endif
prefetch(aroundByteOffset: range.end + 1, forceRestart: true)
}
private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange { private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {
targetWindow(aroundByteOffset: offset, minimumBehind: prefetchChunkSize) targetWindow(aroundByteOffset: offset, minimumBehind: prefetchChunkSize)
} }
@ -539,6 +557,18 @@ final class ProgressiveHTTPRangeCacheSession {
return chunks return chunks
} }
func alignedChunks(covering range: HTTPByteRange) -> [HTTPByteRange] {
let bounded = clamp(range)
var chunks: [HTTPByteRange] = []
var cursor = alignedChunkStart(for: bounded.start)
while cursor <= bounded.end {
let chunk = HTTPByteRange(start: cursor, end: min(contentLength - 1, cursor + prefetchChunkSize - 1))
chunks.append(chunk)
cursor = chunk.end + 1
}
return chunks
}
private func evictOverBudget(protecting range: HTTPByteRange) { private func evictOverBudget(protecting range: HTTPByteRange) {
let headerRange = HTTPByteRange(start: 0, end: min(contentLength - 1, prefetchChunkSize - 1)) let headerRange = HTTPByteRange(start: 0, end: min(contentLength - 1, prefetchChunkSize - 1))
let tailStart = max(0, contentLength - (4 * prefetchChunkSize)) let tailStart = max(0, contentLength - (4 * prefetchChunkSize))

View file

@ -40,7 +40,9 @@ struct StreamResolverTests {
testRangeCachePrefetchPrioritizesSeekOffset() testRangeCachePrefetchPrioritizesSeekOffset()
testRangeCacheSeekPrimingIncludesObservedVLCStart() testRangeCacheSeekPrimingIncludesObservedVLCStart()
testRangeCachePrefetchUsesGlobalChunkBoundaries() testRangeCachePrefetchUsesGlobalChunkBoundaries()
testRangeCacheForegroundMissFetchesAlignedChunks()
await testRangeCacheForegroundMissReprioritizesPrefetch() await testRangeCacheForegroundMissReprioritizesPrefetch()
await testRangeCacheHitFollowsActualPostSeekReadArea()
await testRangeProbeFallsBackWhenServerIgnoresRange() await testRangeProbeFallsBackWhenServerIgnoresRange()
await testRangeFetcherPreservesHeaders() await testRangeFetcherPreservesHeaders()
print("StreamResolverTests passed") print("StreamResolverTests passed")
@ -437,6 +439,18 @@ struct StreamResolverTests {
assertEqual(chunks[0], HTTPByteRange(start: 212_860_928, end: 213_909_503)) assertEqual(chunks[0], HTTPByteRange(start: 212_860_928, end: 213_909_503))
} }
private static func testRangeCacheForegroundMissFetchesAlignedChunks() {
let session = ProgressiveHTTPRangeCacheSession(
fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),
contentLength: 711_080_522,
durationProvider: { 0 }
)
let chunks = session.alignedChunks(covering: HTTPByteRange(start: 48_234_649, end: 49_185_907))
assertEqual(chunks, [HTTPByteRange(start: 48_234_496, end: 49_283_071)])
}
private static func testRangeCacheForegroundMissReprioritizesPrefetch() async { private static func testRangeCacheForegroundMissReprioritizesPrefetch() async {
let queue = DispatchQueue(label: "dreamio.range-cache-test") let queue = DispatchQueue(label: "dreamio.range-cache-test")
var requestedRanges: [String] = [] var requestedRanges: [String] = []
@ -473,7 +487,7 @@ struct StreamResolverTests {
try? await Task.sleep(nanoseconds: 50_000_000) try? await Task.sleep(nanoseconds: 50_000_000)
let ranges = queue.sync { requestedRanges } let ranges = queue.sync { requestedRanges }
assert(ranges.contains("bytes=51818977-52867552"), "Expected foreground VLC range to be fetched") assert(ranges.contains("bytes=51380224-52428799"), "Expected foreground VLC miss to fetch aligned cache chunks")
assert(ranges.contains { range in assert(ranges.contains { range in
range.hasPrefix("bytes=52428800-") range.hasPrefix("bytes=52428800-")
}, "Expected prefetch to restart on a global chunk boundary near VLC's foreground range, got \(ranges)") }, "Expected prefetch to restart on a global chunk boundary near VLC's foreground range, got \(ranges)")
@ -482,6 +496,50 @@ struct StreamResolverTests {
try? await Task.sleep(nanoseconds: 50_000_000) try? await Task.sleep(nanoseconds: 50_000_000)
} }
private static func testRangeCacheHitFollowsActualPostSeekReadArea() async {
let queue = DispatchQueue(label: "dreamio.range-cache-hit-follow-test")
var requestedRanges: [String] = []
MockURLProtocol.handler = { request in
let range = request.value(forHTTPHeaderField: "Range") ?? ""
queue.sync {
requestedRanges.append(range)
}
let byteRange = byteRange(fromHeader: range, contentLength: 80_000_000)
let response = HTTPURLResponse(
url: request.url!,
statusCode: 206,
httpVersion: nil,
headerFields: ["Content-Range": "bytes \(byteRange.start)-\(byteRange.end)/80000000"]
)!
return (Data(repeating: 1, count: Int(byteRange.length)), response)
}
let session = ProgressiveHTTPRangeCacheSession(
fetcher: HTTPRangeRemoteFetcher(
url: URL(string: "https://cdn.example.test/movie.mp4")!,
headers: [:],
session: mockSession()
),
contentLength: 80_000_000,
durationProvider: { 100 }
)
defer {
session.cancelPrefetch()
}
session.store.insert(data: Data(repeating: 7, count: 1_048_576), at: 27_165_812)
session.prefetchForSeek(aroundByteOffset: 15_936_567)
_ = try? await session.data(for: HTTPByteRange(start: 27_165_812, end: 28_214_387))
try? await Task.sleep(nanoseconds: 100_000_000)
let ranges = queue.sync { requestedRanges }
assert(ranges.contains { range in
range.hasPrefix("bytes=27262976-")
}, "Expected a cache hit far from the seek estimate to restart prefetch near VLC's real read area, got \(ranges)")
MockURLProtocol.handler = nil
try? await Task.sleep(nanoseconds: 50_000_000)
}
private static func byteRange(fromHeader header: String, contentLength: Int64) -> HTTPByteRange { private static func byteRange(fromHeader header: String, contentLength: Int64) -> HTTPByteRange {
let value = header.replacingOccurrences(of: "bytes=", with: "") let value = header.replacingOccurrences(of: "bytes=", with: "")
let pieces = value.split(separator: "-", maxSplits: 1).map(String.init) let pieces = value.split(separator: "-", maxSplits: 1).map(String.init)

File diff suppressed because one or more lines are too long