mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 21:38:15 +00:00
Compare commits
6 commits
73eeb09eda
...
46a52b533f
| Author | SHA1 | Date | |
|---|---|---|---|
| 46a52b533f | |||
| adda8b7b02 | |||
| 0f3101ce5c | |||
| 365dfb22f9 | |||
| 6ac2062822 | |||
| 5cd5d2f9ff |
7 changed files with 1530 additions and 25 deletions
|
|
@ -42,3 +42,9 @@
|
|||
{"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."}}
|
||||
{"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-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."}}
|
||||
{"id":"int-ff0aeb09","kind":"field_change","created_at":"2026-05-26T04:47:44.48931Z","actor":"dirtydishes","issue_id":"dreamio-2hw","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed stale local range-cache prefetch state after cached seek reads and documented the validation."}}
|
||||
{"id":"int-204223f5","kind":"field_change","created_at":"2026-05-26T04:56:13.920284Z","actor":"dirtydishes","issue_id":"dreamio-816","extra":{"field":"status","new_value":"closed","old_value":"in_progress","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."}}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
{"_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-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}
|
||||
{"_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-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-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}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ struct HTTPByteRange: Equatable {
|
|||
func merged(with other: HTTPByteRange) -> HTTPByteRange {
|
||||
HTTPByteRange(start: min(start, other.start), end: max(end, other.end))
|
||||
}
|
||||
|
||||
func contains(_ offset: Int64) -> Bool {
|
||||
start <= offset && offset <= end
|
||||
}
|
||||
}
|
||||
|
||||
struct HTTPContentRange: Equatable {
|
||||
|
|
@ -69,6 +73,12 @@ final class SparseHTTPByteRangeStore {
|
|||
}
|
||||
}
|
||||
|
||||
var cachedByteCount: Int64 {
|
||||
lock.withLock {
|
||||
segments.reduce(0) { $0 + Int64($1.data.count) }
|
||||
}
|
||||
}
|
||||
|
||||
func insert(data: Data, at start: Int64) {
|
||||
guard !data.isEmpty else {
|
||||
return
|
||||
|
|
@ -113,6 +123,33 @@ final class SparseHTTPByteRangeStore {
|
|||
data(for: range) != nil
|
||||
}
|
||||
|
||||
func missingRanges(in range: HTTPByteRange) -> [HTTPByteRange] {
|
||||
lock.withLock {
|
||||
var cursor = range.start
|
||||
var missing: [HTTPByteRange] = []
|
||||
|
||||
for segment in segments where segment.range.end >= cursor {
|
||||
guard segment.range.start <= range.end else {
|
||||
break
|
||||
}
|
||||
if segment.range.start > cursor {
|
||||
missing.append(HTTPByteRange(start: cursor, end: min(range.end, segment.range.start - 1)))
|
||||
}
|
||||
if segment.range.end >= cursor {
|
||||
cursor = max(cursor, segment.range.end + 1)
|
||||
}
|
||||
if cursor > range.end {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cursor <= range.end {
|
||||
missing.append(HTTPByteRange(start: cursor, end: range.end))
|
||||
}
|
||||
return missing
|
||||
}
|
||||
}
|
||||
|
||||
func evict(keeping window: HTTPByteRange) {
|
||||
lock.withLock {
|
||||
segments = segments.compactMap { segment in
|
||||
|
|
@ -131,6 +168,61 @@ final class SparseHTTPByteRangeStore {
|
|||
}
|
||||
}
|
||||
|
||||
func evict(toByteBudget byteBudget: Int64, preserving protectedRanges: [HTTPByteRange]) -> [HTTPByteRange] {
|
||||
lock.withLock {
|
||||
guard byteBudget > 0 else {
|
||||
let evicted = segments.map(\.range)
|
||||
segments.removeAll()
|
||||
return evicted
|
||||
}
|
||||
|
||||
var totalBytes = segments.reduce(0) { $0 + Int64($1.data.count) }
|
||||
guard totalBytes > byteBudget else {
|
||||
return []
|
||||
}
|
||||
|
||||
var evicted: [HTTPByteRange] = []
|
||||
while totalBytes > byteBudget,
|
||||
let index = evictionCandidateIndex(protectedRanges: protectedRanges) {
|
||||
let removed = segments.remove(at: index)
|
||||
totalBytes -= Int64(removed.data.count)
|
||||
evicted.append(removed.range)
|
||||
}
|
||||
return evicted
|
||||
}
|
||||
}
|
||||
|
||||
private func evictionCandidateIndex(protectedRanges: [HTTPByteRange]) -> Int? {
|
||||
var bestIndex: Int?
|
||||
var bestDistance: Int64 = .min
|
||||
|
||||
for (index, segment) in segments.enumerated() {
|
||||
if protectedRanges.contains(where: { $0.overlapsOrTouches(segment.range) }) {
|
||||
continue
|
||||
}
|
||||
|
||||
let distance = protectedRanges
|
||||
.map { rangeDistance(from: segment.range, to: $0) }
|
||||
.min() ?? segment.range.start
|
||||
if distance > bestDistance {
|
||||
bestDistance = distance
|
||||
bestIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
return bestIndex
|
||||
}
|
||||
|
||||
private func rangeDistance(from range: HTTPByteRange, to protectedRange: HTTPByteRange) -> Int64 {
|
||||
if range.overlapsOrTouches(protectedRange) {
|
||||
return 0
|
||||
}
|
||||
if range.end < protectedRange.start {
|
||||
return protectedRange.start - range.end
|
||||
}
|
||||
return range.start - protectedRange.end
|
||||
}
|
||||
|
||||
private func mergeSegments() {
|
||||
guard !segments.isEmpty else {
|
||||
return
|
||||
|
|
@ -180,6 +272,9 @@ final class HTTPRangeRemoteFetcher {
|
|||
guard !url.path.lowercased().hasSuffix(".m3u8") else {
|
||||
return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "hls-playlist")
|
||||
}
|
||||
guard !Self.shouldBypassCache(for: url) else {
|
||||
return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "tail-index-container")
|
||||
}
|
||||
|
||||
if let head = try? await response(for: request(method: "HEAD")),
|
||||
(200..<400).contains(head.statusCode) {
|
||||
|
|
@ -238,6 +333,11 @@ final class HTTPRangeRemoteFetcher {
|
|||
private func header(_ name: String, in response: HTTPURLResponse) -> String? {
|
||||
response.value(forHTTPHeaderField: name)
|
||||
}
|
||||
|
||||
private static func shouldBypassCache(for url: URL) -> Bool {
|
||||
let extensionName = url.pathExtension.lowercased()
|
||||
return ["mkv", "mk3d", "mka", "mks", "webm"].contains(extensionName)
|
||||
}
|
||||
}
|
||||
|
||||
enum HTTPRangeCacheError: Error {
|
||||
|
|
@ -253,30 +353,59 @@ final class ProgressiveHTTPRangeCacheSession {
|
|||
let durationProvider: () -> TimeInterval
|
||||
private let prefetchChunkSize: Int64 = 1_048_576
|
||||
private let responseChunkSize: Int64 = 1_048_576
|
||||
private let seekPrimeBehindBytes: Int64 = 4 * 1_048_576
|
||||
private let cacheByteBudget: Int64
|
||||
private var prefetchTask: Task<Void, Never>?
|
||||
private var activePrefetchWindow: HTTPByteRange?
|
||||
private var activePrefetchPreferredOffset: Int64?
|
||||
private var prefetchGeneration: UInt64 = 0
|
||||
private var recentSeekRange: HTTPByteRange?
|
||||
private var recentForegroundRange: HTTPByteRange?
|
||||
|
||||
init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {
|
||||
init(
|
||||
fetcher: HTTPRangeRemoteFetcher,
|
||||
contentLength: Int64,
|
||||
durationProvider: @escaping () -> TimeInterval,
|
||||
cacheByteBudget: Int64 = 64 * 1_048_576
|
||||
) {
|
||||
self.fetcher = fetcher
|
||||
self.contentLength = contentLength
|
||||
self.durationProvider = durationProvider
|
||||
self.cacheByteBudget = cacheByteBudget
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancelPrefetch()
|
||||
}
|
||||
|
||||
func data(for requestedRange: HTTPByteRange) async throws -> Data {
|
||||
let bounded = clamp(requestedRange)
|
||||
recentForegroundRange = bounded
|
||||
if let data = store.data(for: bounded) {
|
||||
#if DEBUG
|
||||
print("[DreamioRangeCache] cache=hit range=\(bounded.start)-\(bounded.end)")
|
||||
#endif
|
||||
prefetchAheadIfForegroundMoved(to: bounded)
|
||||
return data
|
||||
}
|
||||
|
||||
let missingRanges = store.missingRanges(in: bounded)
|
||||
#if DEBUG
|
||||
print("[DreamioRangeCache] cache=miss range=\(bounded.start)-\(bounded.end)")
|
||||
let missKind = missingRanges.count == 1 && missingRanges[0] == bounded ? "uncached" : "partial-miss"
|
||||
print("[DreamioRangeCache] cache=\(missKind) range=\(bounded.start)-\(bounded.end) missing=\(missingRanges.map { "\($0.start)-\($0.end)" }.joined(separator: ","))")
|
||||
#endif
|
||||
let data = try await fetcher.fetch(range: bounded)
|
||||
store.insert(data: data, at: bounded.start)
|
||||
prefetch(aroundByteOffset: bounded.end + 1)
|
||||
return store.data(for: bounded) ?? data
|
||||
cancelPrefetchIfNeeded(forForegroundRange: bounded)
|
||||
for missingRange in missingRanges {
|
||||
for fetchRange in alignedChunks(covering: missingRange) where !store.hasData(for: fetchRange) {
|
||||
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)
|
||||
return store.data(for: bounded) ?? Data()
|
||||
}
|
||||
|
||||
func responseRange(for requestedRange: HTTPByteRange) -> HTTPByteRange {
|
||||
|
|
@ -288,49 +417,197 @@ final class ProgressiveHTTPRangeCacheSession {
|
|||
}
|
||||
|
||||
func prefetch(aroundByteOffset offset: Int64) {
|
||||
prefetchTask?.cancel()
|
||||
let window = targetWindow(aroundByteOffset: offset)
|
||||
store.evict(keeping: window)
|
||||
guard !store.hasData(for: window) else {
|
||||
prefetch(aroundByteOffset: offset, forceRestart: false, startsAtWindowStart: false)
|
||||
}
|
||||
|
||||
func prefetchForSeek(aroundByteOffset offset: Int64) {
|
||||
let window = seekPrimeWindow(aroundByteOffset: offset)
|
||||
recentSeekRange = window
|
||||
prefetch(
|
||||
aroundByteOffset: offset,
|
||||
forceRestart: true,
|
||||
explicitWindow: window,
|
||||
startsAtWindowStart: true
|
||||
)
|
||||
}
|
||||
|
||||
func seekPrimeWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {
|
||||
targetWindow(aroundByteOffset: offset, minimumBehind: seekPrimeBehindBytes)
|
||||
}
|
||||
|
||||
func prefetch(aroundByteOffset offset: Int64, forceRestart: Bool) {
|
||||
prefetch(aroundByteOffset: offset, forceRestart: forceRestart, startsAtWindowStart: false)
|
||||
}
|
||||
|
||||
private func prefetch(
|
||||
aroundByteOffset offset: Int64,
|
||||
forceRestart: Bool,
|
||||
explicitWindow: HTTPByteRange? = nil,
|
||||
startsAtWindowStart: Bool
|
||||
) {
|
||||
if !forceRestart, activePrefetchWindow?.contains(offset) == true, prefetchTask?.isCancelled == false {
|
||||
return
|
||||
}
|
||||
|
||||
prefetchTask = Task { [weak self] in
|
||||
prefetchTask?.cancel()
|
||||
prefetchGeneration += 1
|
||||
let generation = prefetchGeneration
|
||||
let window = explicitWindow ?? targetWindow(aroundByteOffset: offset)
|
||||
activePrefetchWindow = window
|
||||
activePrefetchPreferredOffset = offset
|
||||
evictOverBudget(protecting: window)
|
||||
guard !store.hasData(for: window) else {
|
||||
activePrefetchWindow = nil
|
||||
activePrefetchPreferredOffset = nil
|
||||
return
|
||||
}
|
||||
|
||||
prefetchTask = Task(priority: startsAtWindowStart ? .userInitiated : .utility) { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
var cursor = window.start
|
||||
while cursor <= window.end, !Task.isCancelled {
|
||||
let chunk = HTTPByteRange(start: cursor, end: min(window.end, cursor + prefetchChunkSize - 1))
|
||||
for chunk in self.prefetchChunks(in: window, preferredOffset: offset, startsAtWindowStart: startsAtWindowStart) {
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
if !store.hasData(for: chunk) {
|
||||
do {
|
||||
let data = try await fetcher.fetch(range: chunk)
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
store.insert(data: data, at: chunk.start)
|
||||
evictOverBudget(protecting: window)
|
||||
#if DEBUG
|
||||
print("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)")
|
||||
#endif
|
||||
} catch {
|
||||
if Task.isCancelled {
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
print("[DreamioRangeCache] prefetch failed range=\(chunk.start)-\(chunk.end) error=\(error)")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
}
|
||||
cursor = chunk.end + 1
|
||||
}
|
||||
guard self.prefetchGeneration == generation else {
|
||||
return
|
||||
}
|
||||
self.activePrefetchWindow = nil
|
||||
self.activePrefetchPreferredOffset = nil
|
||||
}
|
||||
}
|
||||
|
||||
func cancelPrefetch() {
|
||||
prefetchTask?.cancel()
|
||||
prefetchGeneration += 1
|
||||
activePrefetchWindow = nil
|
||||
activePrefetchPreferredOffset = nil
|
||||
}
|
||||
|
||||
func byteOffset(for position: Float) -> Int64 {
|
||||
let clamped = max(0, min(1, position))
|
||||
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 prefetchAheadIfForegroundMoved(to range: HTTPByteRange) {
|
||||
guard activePrefetchWindow?.contains(range.start) == true,
|
||||
let preferredOffset = activePrefetchPreferredOffset,
|
||||
abs(range.start - preferredOffset) >= responseChunkSize else {
|
||||
return
|
||||
}
|
||||
let nextOffset = range.end + 1
|
||||
#if DEBUG
|
||||
let reason = nextOffset < preferredOffset ? "reanchor-foreground" : "follow-foreground"
|
||||
print("[DreamioRangeCache] prefetch \(reason) from=\(preferredOffset) to=\(nextOffset)")
|
||||
#endif
|
||||
prefetch(aroundByteOffset: nextOffset, forceRestart: true)
|
||||
}
|
||||
|
||||
private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {
|
||||
targetWindow(aroundByteOffset: offset, minimumBehind: prefetchChunkSize)
|
||||
}
|
||||
|
||||
private func targetWindow(aroundByteOffset offset: Int64, minimumBehind: Int64) -> HTTPByteRange {
|
||||
let bytesPerSecond = estimatedBytesPerSecond()
|
||||
let behind = max(prefetchChunkSize, bytesPerSecond * 30)
|
||||
let behind = max(minimumBehind, bytesPerSecond * 30)
|
||||
let ahead = max(prefetchChunkSize * 2, bytesPerSecond * 60)
|
||||
return clamp(HTTPByteRange(start: offset - behind, end: offset + ahead))
|
||||
let raw = clamp(HTTPByteRange(start: offset - behind, end: offset + ahead))
|
||||
return HTTPByteRange(start: alignedChunkStart(for: raw.start), end: alignedChunkEnd(for: raw.end))
|
||||
}
|
||||
|
||||
func prefetchChunks(in window: HTTPByteRange, preferredOffset offset: Int64) -> [HTTPByteRange] {
|
||||
prefetchChunks(in: window, preferredOffset: offset, startsAtWindowStart: false)
|
||||
}
|
||||
|
||||
func prefetchChunks(in window: HTTPByteRange, preferredOffset offset: Int64, startsAtWindowStart: Bool) -> [HTTPByteRange] {
|
||||
let boundedOffset = max(window.start, min(window.end, offset))
|
||||
let windowStart = alignedChunkStart(for: window.start)
|
||||
let preferredStart = startsAtWindowStart ? windowStart : alignedChunkStart(for: boundedOffset)
|
||||
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 = windowStart
|
||||
while cursor < preferredStart {
|
||||
let chunk = HTTPByteRange(start: cursor, end: min(preferredStart - 1, cursor + prefetchChunkSize - 1))
|
||||
chunks.append(chunk)
|
||||
cursor = chunk.end + 1
|
||||
}
|
||||
|
||||
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) {
|
||||
let headerRange = HTTPByteRange(start: 0, end: min(contentLength - 1, prefetchChunkSize - 1))
|
||||
let tailStart = max(0, contentLength - (4 * prefetchChunkSize))
|
||||
let tailRange = HTTPByteRange(start: tailStart, end: contentLength - 1)
|
||||
let protectedRanges = [range, recentSeekRange, recentForegroundRange, activePrefetchWindow, headerRange, tailRange].compactMap { $0 }
|
||||
let evicted = store.evict(toByteBudget: cacheByteBudget, preserving: protectedRanges)
|
||||
#if DEBUG
|
||||
if !evicted.isEmpty {
|
||||
print("[DreamioRangeCache] evicted reason=budget ranges=\(evicted.map { "\($0.start)-\($0.end)" }.joined(separator: ",")) protected=\(protectedRanges.map { "\($0.start)-\($0.end)" }.joined(separator: ","))")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func alignedChunkStart(for offset: Int64) -> Int64 {
|
||||
max(0, (offset / prefetchChunkSize) * prefetchChunkSize)
|
||||
}
|
||||
|
||||
private func alignedChunkEnd(for offset: Int64) -> Int64 {
|
||||
min(contentLength - 1, alignedChunkStart(for: offset) + prefetchChunkSize - 1)
|
||||
}
|
||||
|
||||
private func estimatedBytesPerSecond() -> Int64 {
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
return
|
||||
}
|
||||
let clamped = max(0, min(1, position))
|
||||
rangeCacheSession?.prefetch(aroundByteOffset: rangeCacheSession?.byteOffset(for: clamped) ?? 0)
|
||||
rangeCacheSession?.prefetchForSeek(aroundByteOffset: rangeCacheSession?.byteOffset(for: clamped) ?? 0)
|
||||
#if DEBUG
|
||||
if let byteOffset = rangeCacheSession?.byteOffset(for: clamped) {
|
||||
print("[DreamioVLC] seek targetPosition=\(clamped) byteOffset=\(byteOffset) mode=local-cache")
|
||||
|
|
@ -171,7 +171,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
let nextTime = max(0, min(duration, currentTime + seconds))
|
||||
if duration > 0 {
|
||||
let nextPosition = Float(nextTime / duration)
|
||||
rangeCacheSession?.prefetch(aroundByteOffset: rangeCacheSession?.byteOffset(for: nextPosition) ?? 0)
|
||||
rangeCacheSession?.prefetchForSeek(aroundByteOffset: rangeCacheSession?.byteOffset(for: nextPosition) ?? 0)
|
||||
#if DEBUG
|
||||
if let byteOffset = rangeCacheSession?.byteOffset(for: nextPosition) {
|
||||
print("[DreamioVLC] jump seconds=\(seconds) target=\(nextTime) byteOffset=\(byteOffset) mode=local-cache")
|
||||
|
|
@ -604,6 +604,13 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
#if canImport(MobileVLCKit)
|
||||
extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
|
||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleMediaPlayerStateChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleMediaPlayerStateChanged() {
|
||||
#if DEBUG
|
||||
logPlaybackStateIfNeeded(stateName(mediaPlayer.state))
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -35,7 +35,15 @@ struct StreamResolverTests {
|
|||
testSparseRangeStoreHitPartialHitAndMiss()
|
||||
testSparseRangeStoreEvictsOutsideWindow()
|
||||
testSparseRangeStoreTrimsOverlappingWindow()
|
||||
testSparseRangeStoreEvictsByBudgetWhilePreservingUsefulRanges()
|
||||
testRangeCacheSessionCapsResponseRange()
|
||||
testRangeCachePrefetchPrioritizesSeekOffset()
|
||||
testRangeCacheSeekPrimingIncludesObservedVLCStart()
|
||||
testRangeCachePrefetchUsesGlobalChunkBoundaries()
|
||||
testRangeCacheForegroundMissFetchesAlignedChunks()
|
||||
await testRangeCacheForegroundMissReprioritizesPrefetch()
|
||||
await testRangeCacheHitFollowsActualPostSeekReadArea()
|
||||
await testRangeProbeBypassesTailIndexContainers()
|
||||
await testRangeProbeFallsBackWhenServerIgnoresRange()
|
||||
await testRangeFetcherPreservesHeaders()
|
||||
print("StreamResolverTests passed")
|
||||
|
|
@ -332,6 +340,28 @@ struct StreamResolverTests {
|
|||
assert(store.data(for: HTTPByteRange(start: 0, end: 5)) == nil, "Expected trimmed bytes outside the window to be evicted")
|
||||
}
|
||||
|
||||
private static func testSparseRangeStoreEvictsByBudgetWhilePreservingUsefulRanges() {
|
||||
let store = SparseHTTPByteRangeStore()
|
||||
|
||||
store.insert(data: Data(repeating: 1, count: 4), at: 0)
|
||||
store.insert(data: Data(repeating: 2, count: 4), at: 100)
|
||||
store.insert(data: Data(repeating: 3, count: 4), at: 200)
|
||||
|
||||
let evicted = store.evict(
|
||||
toByteBudget: 8,
|
||||
preserving: [
|
||||
HTTPByteRange(start: 0, end: 3),
|
||||
HTTPByteRange(start: 190, end: 210)
|
||||
]
|
||||
)
|
||||
|
||||
assertEqual(evicted, [HTTPByteRange(start: 100, end: 103)])
|
||||
assertEqual(store.cachedRanges, [
|
||||
HTTPByteRange(start: 0, end: 3),
|
||||
HTTPByteRange(start: 200, end: 203)
|
||||
])
|
||||
}
|
||||
|
||||
private static func testRangeCacheSessionCapsResponseRange() {
|
||||
let session = ProgressiveHTTPRangeCacheSession(
|
||||
fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),
|
||||
|
|
@ -344,6 +374,210 @@ struct StreamResolverTests {
|
|||
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 testRangeCacheSeekPrimingIncludesObservedVLCStart() {
|
||||
let session = ProgressiveHTTPRangeCacheSession(
|
||||
fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),
|
||||
contentLength: 711_080_522,
|
||||
durationProvider: { 0 }
|
||||
)
|
||||
|
||||
let estimatedOffset: Int64 = 213_615_760
|
||||
let firstVLCRequest = HTTPByteRange(start: 212_942_432, end: 213_991_007)
|
||||
let window = session.seekPrimeWindow(aroundByteOffset: estimatedOffset)
|
||||
let chunks = session.prefetchChunks(
|
||||
in: window,
|
||||
preferredOffset: estimatedOffset,
|
||||
startsAtWindowStart: true
|
||||
)
|
||||
|
||||
let chunkContainingVLCStart = chunks.firstIndex { $0.contains(firstVLCRequest.start) }
|
||||
let chunkContainingEstimatedOffset = chunks.firstIndex { $0.contains(estimatedOffset) }
|
||||
|
||||
assert(chunkContainingVLCStart != nil, "Expected seek priming to include VLC's first request start")
|
||||
assert(chunkContainingEstimatedOffset != nil, "Expected seek priming to include the estimated offset")
|
||||
assert(
|
||||
chunkContainingVLCStart! <= chunkContainingEstimatedOffset!,
|
||||
"Expected bytes behind the seek target to be primed before ahead chunks"
|
||||
)
|
||||
assertEqual(chunks[chunkContainingVLCStart!], HTTPByteRange(start: 212_860_928, end: 213_909_503))
|
||||
}
|
||||
|
||||
private static func testRangeCachePrefetchUsesGlobalChunkBoundaries() {
|
||||
let session = ProgressiveHTTPRangeCacheSession(
|
||||
fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),
|
||||
contentLength: 711_080_522,
|
||||
durationProvider: { 0 }
|
||||
)
|
||||
|
||||
let chunks = session.prefetchChunks(
|
||||
in: HTTPByteRange(start: 213_278_260, end: 216_000_000),
|
||||
preferredOffset: 213_615_760
|
||||
)
|
||||
|
||||
assert(chunks.allSatisfy { $0.start % 1_048_576 == 0 }, "Expected prefetch chunk starts to use stable global 1 MB boundaries: \(chunks)")
|
||||
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 {
|
||||
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=51380224-52428799"), "Expected foreground VLC miss to fetch aligned cache chunks")
|
||||
assert(ranges.contains { range in
|
||||
range.hasPrefix("bytes=52428800-")
|
||||
}, "Expected prefetch to restart on a global chunk boundary near VLC's foreground range, got \(ranges)")
|
||||
session.cancelPrefetch()
|
||||
MockURLProtocol.handler = nil
|
||||
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 testRangeProbeBypassesTailIndexContainers() async {
|
||||
var requestCount = 0
|
||||
MockURLProtocol.handler = { request in
|
||||
requestCount += 1
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 206,
|
||||
httpVersion: nil,
|
||||
headerFields: ["Content-Range": "bytes 0-0/20"]
|
||||
)!
|
||||
return (Data([1]), response)
|
||||
}
|
||||
|
||||
let fetcher = HTTPRangeRemoteFetcher(
|
||||
url: URL(string: "https://cdn.example.test/show.mkv?token=secret")!,
|
||||
headers: [:],
|
||||
session: mockSession()
|
||||
)
|
||||
let probe = await fetcher.probe()
|
||||
|
||||
assertEqual(probe.isCacheable, false)
|
||||
assertEqual(probe.fallbackReason, "tail-index-container")
|
||||
assertEqual(requestCount, 0)
|
||||
MockURLProtocol.handler = nil
|
||||
}
|
||||
|
||||
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 {
|
||||
MockURLProtocol.handler = { request in
|
||||
if request.httpMethod == "HEAD" {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
527
docs/turns/2026-05-26-reduce-vlc-seek-buffering.html
Normal file
527
docs/turns/2026-05-26-reduce-vlc-seek-buffering.html
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue