mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
reprioritize range cache on vlc misses
This commit is contained in:
parent
5cd5d2f9ff
commit
6ac2062822
5 changed files with 244 additions and 2 deletions
|
|
@ -43,3 +43,4 @@
|
||||||
{"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."}}
|
{"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."}}
|
||||||
|
|
|
||||||
|
|
@ -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-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}
|
||||||
{"_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}
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,7 @@ final class ProgressiveHTTPRangeCacheSession {
|
||||||
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?
|
private var activePrefetchWindow: HTTPByteRange?
|
||||||
|
private var activePrefetchPreferredOffset: Int64?
|
||||||
|
|
||||||
init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {
|
init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {
|
||||||
self.fetcher = fetcher
|
self.fetcher = fetcher
|
||||||
|
|
@ -266,6 +267,10 @@ final class ProgressiveHTTPRangeCacheSession {
|
||||||
self.durationProvider = durationProvider
|
self.durationProvider = durationProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
cancelPrefetch()
|
||||||
|
}
|
||||||
|
|
||||||
func data(for requestedRange: HTTPByteRange) async throws -> Data {
|
func data(for requestedRange: HTTPByteRange) async throws -> Data {
|
||||||
let bounded = clamp(requestedRange)
|
let bounded = clamp(requestedRange)
|
||||||
if let data = store.data(for: bounded) {
|
if let data = store.data(for: bounded) {
|
||||||
|
|
@ -278,9 +283,10 @@ final class ProgressiveHTTPRangeCacheSession {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[DreamioRangeCache] cache=miss range=\(bounded.start)-\(bounded.end)")
|
print("[DreamioRangeCache] cache=miss range=\(bounded.start)-\(bounded.end)")
|
||||||
#endif
|
#endif
|
||||||
|
cancelPrefetchIfNeeded(forForegroundRange: bounded)
|
||||||
let data = try await fetcher.fetch(range: bounded)
|
let data = try await fetcher.fetch(range: bounded)
|
||||||
store.insert(data: data, at: bounded.start)
|
store.insert(data: data, at: bounded.start)
|
||||||
prefetch(aroundByteOffset: bounded.end + 1)
|
prefetch(aroundByteOffset: bounded.end + 1, forceRestart: true)
|
||||||
return store.data(for: bounded) ?? data
|
return store.data(for: bounded) ?? data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,16 +299,22 @@ final class ProgressiveHTTPRangeCacheSession {
|
||||||
}
|
}
|
||||||
|
|
||||||
func prefetch(aroundByteOffset offset: Int64) {
|
func prefetch(aroundByteOffset offset: Int64) {
|
||||||
if activePrefetchWindow?.contains(offset) == true, prefetchTask?.isCancelled == false {
|
prefetch(aroundByteOffset: offset, forceRestart: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prefetch(aroundByteOffset offset: Int64, forceRestart: Bool) {
|
||||||
|
if !forceRestart, activePrefetchWindow?.contains(offset) == true, prefetchTask?.isCancelled == false {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
prefetchTask?.cancel()
|
prefetchTask?.cancel()
|
||||||
let window = targetWindow(aroundByteOffset: offset)
|
let window = targetWindow(aroundByteOffset: offset)
|
||||||
activePrefetchWindow = window
|
activePrefetchWindow = window
|
||||||
|
activePrefetchPreferredOffset = offset
|
||||||
store.evict(keeping: window)
|
store.evict(keeping: window)
|
||||||
guard !store.hasData(for: window) else {
|
guard !store.hasData(for: window) else {
|
||||||
activePrefetchWindow = nil
|
activePrefetchWindow = nil
|
||||||
|
activePrefetchPreferredOffset = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,14 +345,33 @@ final class ProgressiveHTTPRangeCacheSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.activePrefetchWindow = nil
|
self.activePrefetchWindow = nil
|
||||||
|
self.activePrefetchPreferredOffset = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cancelPrefetch() {
|
||||||
|
prefetchTask?.cancel()
|
||||||
|
activePrefetchWindow = nil
|
||||||
|
activePrefetchPreferredOffset = nil
|
||||||
|
}
|
||||||
|
|
||||||
func byteOffset(for position: Float) -> Int64 {
|
func byteOffset(for position: Float) -> Int64 {
|
||||||
let clamped = max(0, min(1, position))
|
let clamped = max(0, min(1, position))
|
||||||
return Int64(Float(contentLength) * clamped)
|
return Int64(Float(contentLength) * clamped)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func cancelPrefetchIfNeeded(forForegroundRange range: HTTPByteRange) {
|
||||||
|
guard activePrefetchWindow?.contains(range.start) == true,
|
||||||
|
let preferredOffset = activePrefetchPreferredOffset,
|
||||||
|
abs(range.start - preferredOffset) >= responseChunkSize else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioRangeCache] prefetch reprioritize from=\(preferredOffset) to=\(range.start)")
|
||||||
|
#endif
|
||||||
|
cancelPrefetch()
|
||||||
|
}
|
||||||
|
|
||||||
private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {
|
private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {
|
||||||
let bytesPerSecond = estimatedBytesPerSecond()
|
let bytesPerSecond = estimatedBytesPerSecond()
|
||||||
let behind = max(prefetchChunkSize, bytesPerSecond * 30)
|
let behind = max(prefetchChunkSize, bytesPerSecond * 30)
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ struct StreamResolverTests {
|
||||||
testSparseRangeStoreTrimsOverlappingWindow()
|
testSparseRangeStoreTrimsOverlappingWindow()
|
||||||
testRangeCacheSessionCapsResponseRange()
|
testRangeCacheSessionCapsResponseRange()
|
||||||
testRangeCachePrefetchPrioritizesSeekOffset()
|
testRangeCachePrefetchPrioritizesSeekOffset()
|
||||||
|
await testRangeCacheForegroundMissReprioritizesPrefetch()
|
||||||
await testRangeProbeFallsBackWhenServerIgnoresRange()
|
await testRangeProbeFallsBackWhenServerIgnoresRange()
|
||||||
await testRangeFetcherPreservesHeaders()
|
await testRangeFetcherPreservesHeaders()
|
||||||
print("StreamResolverTests passed")
|
print("StreamResolverTests passed")
|
||||||
|
|
@ -367,6 +368,62 @@ struct StreamResolverTests {
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func testRangeCacheForegroundMissReprioritizesPrefetch() async {
|
||||||
|
let queue = DispatchQueue(label: "dreamio.range-cache-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.prefetch(aroundByteOffset: 28_242_716)
|
||||||
|
_ = try? await session.data(for: HTTPByteRange(start: 51_818_977, end: 52_867_552))
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
|
||||||
|
let ranges = queue.sync { requestedRanges }
|
||||||
|
assert(ranges.contains("bytes=51818977-52867552"), "Expected foreground VLC range to be fetched")
|
||||||
|
assert(ranges.contains { range in
|
||||||
|
range.hasPrefix("bytes=51936225-")
|
||||||
|
}, "Expected prefetch to restart near VLC's foreground range, got \(ranges)")
|
||||||
|
session.cancelPrefetch()
|
||||||
|
MockURLProtocol.handler = nil
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func byteRange(fromHeader header: String, contentLength: Int64) -> HTTPByteRange {
|
||||||
|
let value = header.replacingOccurrences(of: "bytes=", with: "")
|
||||||
|
let pieces = value.split(separator: "-", maxSplits: 1).map(String.init)
|
||||||
|
guard pieces.count == 2,
|
||||||
|
let start = Int64(pieces[0]) else {
|
||||||
|
return HTTPByteRange(start: 0, end: 0)
|
||||||
|
}
|
||||||
|
let end = pieces[1].isEmpty ? contentLength - 1 : (Int64(pieces[1]) ?? contentLength - 1)
|
||||||
|
return HTTPByteRange(start: start, end: min(end, contentLength - 1))
|
||||||
|
}
|
||||||
|
|
||||||
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
Loading…
Add table
Add a link
Reference in a new issue