mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
add vlc local range cache
This commit is contained in:
parent
4815c3a7f6
commit
e7a80df7cc
7 changed files with 1029 additions and 18 deletions
|
|
@ -38,3 +38,6 @@
|
|||
{"id":"int-697dc66d","kind":"field_change","created_at":"2026-05-25T17:01:32.697187Z","actor":"dirtydishes","issue_id":"dreamio-0lt","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"not implementing now; user asked only to move previous work to the audio-track-selection branch"}}
|
||||
{"id":"int-c9b3bcd7","kind":"field_change","created_at":"2026-05-25T17:48:09.142384Z","actor":"dirtydishes","issue_id":"dreamio-ejh","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by preserving known external subtitle display names for generic VLC subtitle tracks and expanding language-code aliases."}}
|
||||
{"id":"int-12bf46aa","kind":"field_change","created_at":"2026-05-25T18:31:50.873069Z","actor":"dirtydishes","issue_id":"dreamio-kdf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tracked Xcode user interface state files were removed from the git index, and existing ignore rules now cover regenerated xcuserdata files."}}
|
||||
{"id":"int-c15b9cb9","kind":"field_change","created_at":"2026-05-25T19:07:23.629637Z","actor":"dirtydishes","issue_id":"dreamio-3yb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added centralized 30-second VLC media caching for native playback and validated the iOS build."}}
|
||||
{"id":"int-e339ed64","kind":"field_change","created_at":"2026-05-25T20:22:40.999137Z","actor":"dirtydishes","issue_id":"dreamio-dsp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"implemented local native stream cache proxy with range cache tests and successful simulator build"}}
|
||||
{"id":"int-79713eba","kind":"field_change","created_at":"2026-05-25T21:55:32.323229Z","actor":"dirtydishes","issue_id":"dreamio-6bv","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"handled VLC buffering follow-up by supporting HEAD probes, moving fetch work off listener queue, reducing foreground range size, and locking cache access"}}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
{"_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-6bv","title":"fix native stream proxy buffering after seek","description":"Investigate and fix VLC staying in buffering after native proxy-backed jump seeks. Logs show time remains pinned after jump while state repeatedly reports buffering.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T21:53:57Z","created_by":"dirtydishes","updated_at":"2026-05-25T21:55:32Z","started_at":"2026-05-25T21:54:01Z","closed_at":"2026-05-25T21:55:32Z","close_reason":"handled VLC buffering follow-up by supporting HEAD probes, moving fetch work off listener queue, reducing foreground range size, and locking cache access","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-dsp","title":"add local seek buffer proxy for native playback","description":"Implement a local HTTP range proxy/cache between VLC and direct-file streams so nearby seeks can use retained bytes, while preserving stream headers and subtitle behavior.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T20:18:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T20:22:41Z","started_at":"2026-05-25T20:18:47Z","closed_at":"2026-05-25T20:22:41Z","close_reason":"implemented local native stream cache proxy with range cache tests and successful simulator build","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-ejh","title":"Preserve external subtitle language names in VLC captions menu","description":"VLC can surface externally attached subtitle slaves as generic Track N labels even though Dreamio already knows the OpenSubtitles language metadata. Preserve and apply that metadata when building the native captions menu so users can distinguish subtitle languages.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T17:46:38Z","created_by":"dirtydishes","updated_at":"2026-05-25T17:48:09Z","started_at":"2026-05-25T17:46:43Z","closed_at":"2026-05-25T17:48:09Z","close_reason":"Fixed by preserving known external subtitle display names for generic VLC subtitle tracks and expanding language-code aliases.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-9sp","title":"Accept Stremio subtitle download URLs","description":"Runtime logs show Stremio external subtitle tracks using subs5.strem.io /en/download URLs. The subtitle bridge and Swift parser currently reject those URLs because they do not have a subtitle file extension and are not on an OpenSubtitles host, so native playback receives zero external subtitle candidates.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:32:04Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:33:55Z","started_at":"2026-05-25T16:32:10Z","closed_at":"2026-05-25T16:33:55Z","close_reason":"Accepted Stremio subtitle download URLs in the bridge, parser, resolver, and regression tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-433","title":"Filter false OpenSubtitles subtitle candidates","description":"Dreamio is treating addon artwork and OpenSubtitles addon endpoints as external subtitle candidates, which causes the native player UI to show only embedded subtitles. Tighten subtitle URL detection in the web bridge and Swift parser, and add regression coverage for the logged false positives.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:20:47Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:22:50Z","started_at":"2026-05-25T16:20:50Z","closed_at":"2026-05-25T16:22:50Z","close_reason":"Fixed by tightening OpenSubtitles subtitle URL filtering in the web bridge and Swift parser, plus adding regression coverage for logged artwork and addon endpoint false positives.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
|
@ -24,6 +26,7 @@
|
|||
{"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-3yb","title":"Add VLC seek buffer for native playback","description":"Configure balanced VLC media caching in the native playback backend so short seek jumps are less likely to feel like stream restarts while preserving existing playback controls, audio tracks, and subtitles.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T19:05:27Z","created_by":"dirtydishes","updated_at":"2026-05-25T19:07:24Z","started_at":"2026-05-25T19:05:31Z","closed_at":"2026-05-25T19:07:24Z","close_reason":"Added centralized 30-second VLC media caching for native playback and validated the iOS build.","comments":[{"id":"019e60b6-9b11-7c5f-af57-c9e54ac6129f","issue_id":"dreamio-3yb","author":"dirtydishes","text":"Device testing still shows repeated VLC buffering after 15-second jumps. Added DEBUG playback snapshots on state changes and delayed post-jump probes so the next pass can distinguish a stalled stream/range reconnect from a seek-state issue.","created_at":"2026-05-25T19:57:21Z"},{"id":"019e60c4-f824-79fe-b974-9fbe9fe91788","issue_id":"dreamio-3yb","author":"dirtydishes","text":"Latest device logs show VLC remains at the pre-jump time/position after a fixed skip while buffering. Added backend-only stalled jump recovery: after a short no-progress buffering window, reopen the same media with :start-time set to the requested target and reattach accepted subtitle candidates plus selected tracks where available.","created_at":"2026-05-25T20:13:02Z"}],"dependency_count":0,"dependent_count":0,"comment_count":2}
|
||||
{"_type":"issue","id":"dreamio-kdf","title":"Stop tracking Xcode user state","description":"Xcode user interface state files are machine-specific and currently tracked, which causes noisy local modifications and pull conflicts. Remove tracked xcuserstate files from the git index while keeping ignore rules in place.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T18:31:36Z","created_by":"dirtydishes","updated_at":"2026-05-25T18:31:51Z","started_at":"2026-05-25T18:31:39Z","closed_at":"2026-05-25T18:31:51Z","close_reason":"Tracked Xcode user interface state files were removed from the git index, and existing ignore rules now cover regenerated xcuserdata files.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-0lt","title":"add audio track selection","description":"Add native player support for viewing and switching available audio tracks during playback.","status":"closed","priority":2,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-25T17:00:53Z","created_by":"dirtydishes","updated_at":"2026-05-25T17:01:33Z","closed_at":"2026-05-25T17:01:33Z","close_reason":"not implementing now; user asked only to move previous work to the audio-track-selection branch","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-2ju","title":"Show OpenSubtitles languages in caption tracks","description":"Preserve external subtitle metadata after VLC attaches OpenSubtitles tracks so the captions menu shows useful language labels instead of generic VLC track names.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:52:44Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:54:58Z","started_at":"2026-05-25T16:52:49Z","closed_at":"2026-05-25T16:54:58Z","close_reason":"Fixed by preserving OpenSubtitles subtitle display metadata through VLC external track attachment and adding display-name tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@
|
|||
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */; };
|
||||
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */; };
|
||||
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */; };
|
||||
BA013CEC876B829A86AE8DCB /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; };
|
||||
6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */; };
|
||||
8BC00A493F84BEC6714B8F14 /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
|
|
@ -29,6 +30,7 @@
|
|||
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCNativePlaybackBackend.swift; sourceTree = "<group>"; };
|
||||
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = "<group>"; };
|
||||
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.swift; sourceTree = "<group>"; };
|
||||
6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveHTTPRangeCache.swift; sourceTree = "<group>"; };
|
||||
701702B9C2BFBEDE36E7F0A3 /* Pods-Dreamio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.release.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.release.xcconfig"; sourceTree = "<group>"; };
|
||||
908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Dreamio.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BF0A4D5BAC9400AEEF3B0181 /* Pods-Dreamio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.debug.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
|
|
@ -39,6 +41,7 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
8BC00A493F84BEC6714B8F14 /* Pods_Dreamio.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -90,6 +93,7 @@
|
|||
6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */,
|
||||
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,
|
||||
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,
|
||||
6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */,
|
||||
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,
|
||||
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,
|
||||
6F2A2B392C00100100DREAMIO /* Info.plist */,
|
||||
|
|
@ -236,6 +240,7 @@
|
|||
6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */,
|
||||
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */,
|
||||
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */,
|
||||
6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */,
|
||||
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */,
|
||||
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */,
|
||||
);
|
||||
|
|
|
|||
464
Dreamio/ProgressiveHTTPRangeCache.swift
Normal file
464
Dreamio/ProgressiveHTTPRangeCache.swift
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
import Foundation
|
||||
import Network
|
||||
|
||||
struct HTTPByteRange: Equatable {
|
||||
let start: Int64
|
||||
let end: Int64
|
||||
|
||||
var length: Int64 {
|
||||
max(0, end - start + 1)
|
||||
}
|
||||
|
||||
func overlapsOrTouches(_ other: HTTPByteRange) -> Bool {
|
||||
start <= other.end + 1 && other.start <= end + 1
|
||||
}
|
||||
|
||||
func merged(with other: HTTPByteRange) -> HTTPByteRange {
|
||||
HTTPByteRange(start: min(start, other.start), end: max(end, other.end))
|
||||
}
|
||||
}
|
||||
|
||||
struct HTTPContentRange: Equatable {
|
||||
let range: HTTPByteRange
|
||||
let totalLength: Int64?
|
||||
|
||||
static func parse(_ value: String) -> HTTPContentRange? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.lowercased().hasPrefix("bytes ") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let body = trimmed.dropFirst("bytes ".count)
|
||||
let pieces = body.split(separator: "/", maxSplits: 1).map(String.init)
|
||||
guard pieces.count == 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let rangePieces = pieces[0].split(separator: "-", maxSplits: 1).map(String.init)
|
||||
guard rangePieces.count == 2,
|
||||
let start = Int64(rangePieces[0]),
|
||||
let end = Int64(rangePieces[1]),
|
||||
start >= 0,
|
||||
end >= start else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let total = pieces[1] == "*" ? nil : Int64(pieces[1])
|
||||
return HTTPContentRange(range: HTTPByteRange(start: start, end: end), totalLength: total)
|
||||
}
|
||||
}
|
||||
|
||||
struct HTTPRangeProbeResult {
|
||||
let isCacheable: Bool
|
||||
let contentLength: Int64?
|
||||
let fallbackReason: String?
|
||||
}
|
||||
|
||||
final class SparseHTTPByteRangeStore {
|
||||
private struct Segment {
|
||||
var range: HTTPByteRange
|
||||
var data: Data
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var segments: [Segment] = []
|
||||
|
||||
var cachedRanges: [HTTPByteRange] {
|
||||
lock.withLock {
|
||||
segments.map(\.range)
|
||||
}
|
||||
}
|
||||
|
||||
func insert(data: Data, at start: Int64) {
|
||||
guard !data.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let insertedRange = HTTPByteRange(start: start, end: start + Int64(data.count) - 1)
|
||||
lock.withLock {
|
||||
segments.append(Segment(range: insertedRange, data: data))
|
||||
segments.sort { $0.range.start < $1.range.start }
|
||||
mergeSegments()
|
||||
}
|
||||
}
|
||||
|
||||
func data(for range: HTTPByteRange) -> Data? {
|
||||
lock.withLock {
|
||||
guard let firstIndex = segments.firstIndex(where: { $0.range.start <= range.start && $0.range.end >= range.start }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var cursor = range.start
|
||||
var result = Data()
|
||||
for segment in segments[firstIndex...] {
|
||||
guard segment.range.start <= cursor, segment.range.end >= cursor else {
|
||||
break
|
||||
}
|
||||
|
||||
let readEnd = min(segment.range.end, range.end)
|
||||
let lower = Int(cursor - segment.range.start)
|
||||
let upper = Int(readEnd - segment.range.start + 1)
|
||||
result.append(segment.data.subdata(in: lower..<upper))
|
||||
cursor = readEnd + 1
|
||||
|
||||
if cursor > range.end {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func hasData(for range: HTTPByteRange) -> Bool {
|
||||
data(for: range) != nil
|
||||
}
|
||||
|
||||
func evict(keeping window: HTTPByteRange) {
|
||||
lock.withLock {
|
||||
segments.removeAll { !$0.range.overlapsOrTouches(window) }
|
||||
}
|
||||
}
|
||||
|
||||
private func mergeSegments() {
|
||||
guard !segments.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
var merged: [Segment] = []
|
||||
for segment in segments {
|
||||
guard var previous = merged.popLast() else {
|
||||
merged.append(segment)
|
||||
continue
|
||||
}
|
||||
|
||||
guard previous.range.overlapsOrTouches(segment.range) else {
|
||||
merged.append(previous)
|
||||
merged.append(segment)
|
||||
continue
|
||||
}
|
||||
|
||||
if segment.range.end > previous.range.end {
|
||||
let overlap = max(0, previous.range.end - segment.range.start + 1)
|
||||
if overlap < Int64(segment.data.count) {
|
||||
previous.data.append(segment.data.dropFirst(Int(overlap)))
|
||||
}
|
||||
previous.range = previous.range.merged(with: segment.range)
|
||||
}
|
||||
merged.append(previous)
|
||||
}
|
||||
segments = merged
|
||||
}
|
||||
}
|
||||
|
||||
final class HTTPRangeRemoteFetcher {
|
||||
let url: URL
|
||||
let headers: [String: String]
|
||||
private let session: URLSession
|
||||
|
||||
init(url: URL, headers: [String: String], session: URLSession = .shared) {
|
||||
self.url = url
|
||||
self.headers = headers
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func probe() async -> HTTPRangeProbeResult {
|
||||
guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {
|
||||
return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "non-http-url")
|
||||
}
|
||||
guard !url.path.lowercased().hasSuffix(".m3u8") else {
|
||||
return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "hls-playlist")
|
||||
}
|
||||
|
||||
if let head = try? await response(for: request(method: "HEAD")),
|
||||
(200..<400).contains(head.statusCode) {
|
||||
let acceptsRanges = header("Accept-Ranges", in: head)?.lowercased().contains("bytes") == true
|
||||
let length = header("Content-Length", in: head).flatMap(Int64.init)
|
||||
if acceptsRanges, let length, length > 0 {
|
||||
return HTTPRangeProbeResult(isCacheable: true, contentLength: length, fallbackReason: nil)
|
||||
}
|
||||
}
|
||||
|
||||
var tinyRequest = request(method: "GET")
|
||||
tinyRequest.setValue("bytes=0-0", forHTTPHeaderField: "Range")
|
||||
do {
|
||||
let (data, response) = try await session.data(for: tinyRequest)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "probe-non-http-response")
|
||||
}
|
||||
guard http.statusCode == 206,
|
||||
let contentRange = header("Content-Range", in: http).flatMap(HTTPContentRange.parse),
|
||||
data.count <= 1 else {
|
||||
return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "range-probe-status-\(http.statusCode)")
|
||||
}
|
||||
return HTTPRangeProbeResult(isCacheable: true, contentLength: contentRange.totalLength, fallbackReason: nil)
|
||||
} catch {
|
||||
return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "range-probe-error-\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func fetch(range: HTTPByteRange) async throws -> Data {
|
||||
var rangeRequest = request(method: "GET")
|
||||
rangeRequest.setValue("bytes=\(range.start)-\(range.end)", forHTTPHeaderField: "Range")
|
||||
let (data, response) = try await session.data(for: rangeRequest)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw HTTPRangeCacheError.remoteRejectedRange("non-http-response")
|
||||
}
|
||||
guard http.statusCode == 206 else {
|
||||
throw HTTPRangeCacheError.remoteRejectedRange("status-\(http.statusCode)")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private func response(for request: URLRequest) async throws -> HTTPURLResponse? {
|
||||
let (_, response) = try await session.data(for: request)
|
||||
return response as? HTTPURLResponse
|
||||
}
|
||||
|
||||
private func request(method: String) -> URLRequest {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
headers.forEach { key, value in
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
private func header(_ name: String, in response: HTTPURLResponse) -> String? {
|
||||
response.value(forHTTPHeaderField: name)
|
||||
}
|
||||
}
|
||||
|
||||
enum HTTPRangeCacheError: Error {
|
||||
case remoteRejectedRange(String)
|
||||
case serverUnavailable
|
||||
}
|
||||
|
||||
final class ProgressiveHTTPRangeCacheSession {
|
||||
let id = UUID().uuidString
|
||||
let store = SparseHTTPByteRangeStore()
|
||||
let fetcher: HTTPRangeRemoteFetcher
|
||||
let contentLength: Int64
|
||||
let durationProvider: () -> TimeInterval
|
||||
private let prefetchChunkSize: Int64 = 1_048_576
|
||||
private var prefetchTask: Task<Void, Never>?
|
||||
|
||||
init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {
|
||||
self.fetcher = fetcher
|
||||
self.contentLength = contentLength
|
||||
self.durationProvider = durationProvider
|
||||
}
|
||||
|
||||
func data(for requestedRange: HTTPByteRange) async throws -> Data {
|
||||
let bounded = clamp(requestedRange)
|
||||
if let data = store.data(for: bounded) {
|
||||
#if DEBUG
|
||||
print("[DreamioRangeCache] cache=hit range=\(bounded.start)-\(bounded.end)")
|
||||
#endif
|
||||
return data
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("[DreamioRangeCache] cache=miss range=\(bounded.start)-\(bounded.end)")
|
||||
#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
|
||||
}
|
||||
|
||||
func prefetch(aroundByteOffset offset: Int64) {
|
||||
prefetchTask?.cancel()
|
||||
let window = targetWindow(aroundByteOffset: offset)
|
||||
store.evict(keeping: window)
|
||||
guard !store.hasData(for: window) else {
|
||||
return
|
||||
}
|
||||
|
||||
prefetchTask = Task { [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))
|
||||
if !store.hasData(for: chunk) {
|
||||
do {
|
||||
let data = try await fetcher.fetch(range: chunk)
|
||||
store.insert(data: data, at: chunk.start)
|
||||
#if DEBUG
|
||||
print("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)")
|
||||
#endif
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[DreamioRangeCache] prefetch failed range=\(chunk.start)-\(chunk.end) error=\(error)")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
}
|
||||
cursor = chunk.end + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func byteOffset(for position: Float) -> Int64 {
|
||||
let clamped = max(0, min(1, position))
|
||||
return Int64(Float(contentLength) * clamped)
|
||||
}
|
||||
|
||||
private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {
|
||||
let bytesPerSecond = estimatedBytesPerSecond()
|
||||
let behind = max(prefetchChunkSize, bytesPerSecond * 30)
|
||||
let ahead = max(prefetchChunkSize * 2, bytesPerSecond * 60)
|
||||
return clamp(HTTPByteRange(start: offset - behind, end: offset + ahead))
|
||||
}
|
||||
|
||||
private func estimatedBytesPerSecond() -> Int64 {
|
||||
let duration = durationProvider()
|
||||
guard duration > 1 else {
|
||||
return 512_000
|
||||
}
|
||||
return max(1, Int64(Double(contentLength) / duration))
|
||||
}
|
||||
|
||||
private func clamp(_ range: HTTPByteRange) -> HTTPByteRange {
|
||||
HTTPByteRange(
|
||||
start: max(0, min(contentLength - 1, range.start)),
|
||||
end: max(0, min(contentLength - 1, range.end))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class ProgressiveHTTPRangeCacheServer {
|
||||
static let shared = ProgressiveHTTPRangeCacheServer()
|
||||
|
||||
private let queue = DispatchQueue(label: "dreamio.range-cache.server")
|
||||
private var listener: NWListener?
|
||||
private var port: UInt16?
|
||||
private var sessions: [String: ProgressiveHTTPRangeCacheSession] = [:]
|
||||
|
||||
func localURL(for session: ProgressiveHTTPRangeCacheSession) throws -> URL {
|
||||
try startIfNeeded()
|
||||
sessions[session.id] = session
|
||||
guard let port,
|
||||
let url = URL(string: "http://127.0.0.1:\(port)/stream/\(session.id)") else {
|
||||
throw HTTPRangeCacheError.serverUnavailable
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private func startIfNeeded() throws {
|
||||
guard listener == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
let listener = try NWListener(using: .tcp, on: .any)
|
||||
listener.newConnectionHandler = { [weak self] connection in
|
||||
self?.handle(connection)
|
||||
}
|
||||
listener.start(queue: queue)
|
||||
self.listener = listener
|
||||
self.port = listener.port.map { UInt16($0.rawValue) }
|
||||
}
|
||||
|
||||
private func handle(_ connection: NWConnection) {
|
||||
connection.start(queue: queue)
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 16_384) { [weak self] data, _, _, _ in
|
||||
guard let self, let data, let requestText = String(data: data, encoding: .utf8) else {
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await self.respond(to: requestText, on: connection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func respond(to requestText: String, on connection: NWConnection) async {
|
||||
guard let requestLine = requestText.components(separatedBy: "\r\n").first else {
|
||||
send(status: "400 Bad Request", headers: [:], body: Data(), on: connection)
|
||||
return
|
||||
}
|
||||
|
||||
let parts = requestLine.split(separator: " ")
|
||||
guard parts.count >= 2,
|
||||
parts[0] == "GET",
|
||||
let path = parts[safe: 1],
|
||||
path.hasPrefix("/stream/") else {
|
||||
send(status: "404 Not Found", headers: [:], body: Data(), on: connection)
|
||||
return
|
||||
}
|
||||
|
||||
let id = String(path.dropFirst("/stream/".count))
|
||||
guard let session = sessions[id] else {
|
||||
send(status: "404 Not Found", headers: [:], body: Data(), on: connection)
|
||||
return
|
||||
}
|
||||
|
||||
let requestedRange = parseRangeHeader(in: requestText, contentLength: session.contentLength)
|
||||
?? HTTPByteRange(start: 0, end: min(session.contentLength - 1, 1_048_575))
|
||||
do {
|
||||
let data = try await session.data(for: requestedRange)
|
||||
let headers = [
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": "\(data.count)",
|
||||
"Content-Range": "bytes \(requestedRange.start)-\(requestedRange.end)/\(session.contentLength)",
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Connection": "close"
|
||||
]
|
||||
send(status: "206 Partial Content", headers: headers, body: data, on: connection)
|
||||
} catch {
|
||||
send(status: "502 Bad Gateway", headers: ["Connection": "close"], body: Data(), on: connection)
|
||||
}
|
||||
}
|
||||
|
||||
private func parseRangeHeader(in request: String, contentLength: Int64) -> HTTPByteRange? {
|
||||
let lines = request.components(separatedBy: "\r\n")
|
||||
guard let line = lines.first(where: { $0.lowercased().hasPrefix("range:") }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let value = line.dropFirst("Range:".count).trimmingCharacters(in: .whitespaces)
|
||||
guard value.lowercased().hasPrefix("bytes=") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let rangeValue = value.dropFirst("bytes=".count)
|
||||
let pieces = rangeValue.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard pieces.count == 2,
|
||||
let start = Int64(pieces[0]) else {
|
||||
return nil
|
||||
}
|
||||
let end = pieces[1].isEmpty ? contentLength - 1 : (Int64(pieces[1]) ?? contentLength - 1)
|
||||
guard start >= 0, end >= start else {
|
||||
return nil
|
||||
}
|
||||
return HTTPByteRange(start: start, end: min(end, contentLength - 1))
|
||||
}
|
||||
|
||||
private func send(status: String, headers: [String: String], body: Data, on connection: NWConnection) {
|
||||
var response = "HTTP/1.1 \(status)\r\n"
|
||||
headers.forEach { key, value in
|
||||
response += "\(key): \(value)\r\n"
|
||||
}
|
||||
response += "\r\n"
|
||||
var payload = Data(response.utf8)
|
||||
payload.append(body)
|
||||
connection.send(content: payload, completion: .contentProcessed { _ in
|
||||
connection.cancel()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSLock {
|
||||
func withLock<T>(_ body: () -> T) -> T {
|
||||
lock()
|
||||
defer { unlock() }
|
||||
return body()
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
subscript(safe index: Index) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,10 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
#if canImport(MobileVLCKit)
|
||||
private let mediaPlayer = VLCMediaPlayer()
|
||||
#endif
|
||||
private var rangeCacheSession: ProgressiveHTTPRangeCacheSession?
|
||||
private var playbackStartupTask: Task<Void, Never>?
|
||||
private var lastLoggedState: String?
|
||||
private var lastBufferingLogTime: Date?
|
||||
private var attachedSubtitleURLs = Set<URL>()
|
||||
private var didAutoSelectSubtitleTrack = false
|
||||
private var didUserSelectSubtitleTrack = false
|
||||
|
|
@ -48,6 +52,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
|
||||
func play(request: NativePlaybackRequest) {
|
||||
#if canImport(MobileVLCKit)
|
||||
playbackStartupTask?.cancel()
|
||||
attachedSubtitleURLs.removeAll()
|
||||
didAutoSelectSubtitleTrack = false
|
||||
didUserSelectSubtitleTrack = false
|
||||
|
|
@ -56,23 +61,63 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
hasPendingExternalSubtitleSelection = false
|
||||
pendingExternalSubtitleDisplayNames.removeAll()
|
||||
externalSubtitleDisplayNamesByTrackID.removeAll()
|
||||
let media = VLCMedia(url: request.playbackURL)
|
||||
let headerValue = request.headers
|
||||
.map { "\($0.key): \($0.value)" }
|
||||
.joined(separator: "\r\n")
|
||||
media.addOption(":http-referrer=\(request.referer)")
|
||||
if let userAgent = request.userAgent {
|
||||
media.addOption(":http-user-agent=\(userAgent)")
|
||||
}
|
||||
if !headerValue.isEmpty {
|
||||
media.addOption(":http-header=\(headerValue)")
|
||||
}
|
||||
|
||||
mediaPlayer.media = media
|
||||
rangeCacheSession = nil
|
||||
lastLoggedState = nil
|
||||
lastBufferingLogTime = nil
|
||||
#if DEBUG
|
||||
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
|
||||
print("[DreamioVLC] cache-probe url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
|
||||
#endif
|
||||
mediaPlayer.play()
|
||||
playbackStartupTask = Task { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
let fetcher = HTTPRangeRemoteFetcher(url: request.playbackURL, headers: request.headers)
|
||||
let probe = await fetcher.probe()
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
if probe.isCacheable, let contentLength = probe.contentLength, contentLength > 0 {
|
||||
do {
|
||||
let session = ProgressiveHTTPRangeCacheSession(
|
||||
fetcher: fetcher,
|
||||
contentLength: contentLength,
|
||||
durationProvider: { [weak self] in self?.duration ?? 0 }
|
||||
)
|
||||
let localURL = try ProgressiveHTTPRangeCacheServer.shared.localURL(for: session)
|
||||
await MainActor.run {
|
||||
self.rangeCacheSession = session
|
||||
session.prefetch(aroundByteOffset: 0)
|
||||
self.startVLCMedia(
|
||||
url: localURL,
|
||||
request: request,
|
||||
playbackMode: "local-cache",
|
||||
cachingMilliseconds: 500,
|
||||
includeRemoteHTTPOptions: false
|
||||
)
|
||||
}
|
||||
return
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[DreamioVLC] cache fallback reason=local-server-error-\(error)")
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("[DreamioVLC] cache fallback reason=\(probe.fallbackReason ?? "unknown")")
|
||||
#endif
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.startVLCMedia(
|
||||
url: request.playbackURL,
|
||||
request: request,
|
||||
playbackMode: "direct",
|
||||
cachingMilliseconds: 2500,
|
||||
includeRemoteHTTPOptions: true
|
||||
)
|
||||
}
|
||||
}
|
||||
#else
|
||||
onFailure?(NativePlaybackError.backendUnavailable)
|
||||
#endif
|
||||
|
|
@ -99,7 +144,16 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
guard isSeekable else {
|
||||
return
|
||||
}
|
||||
mediaPlayer.position = max(0, min(1, position))
|
||||
let clamped = max(0, min(1, position))
|
||||
rangeCacheSession?.prefetch(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")
|
||||
} else {
|
||||
print("[DreamioVLC] seek targetPosition=\(clamped) mode=direct")
|
||||
}
|
||||
#endif
|
||||
mediaPlayer.position = clamped
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
@ -109,6 +163,17 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
return
|
||||
}
|
||||
let nextTime = max(0, min(duration, currentTime + seconds))
|
||||
if duration > 0 {
|
||||
let nextPosition = Float(nextTime / duration)
|
||||
rangeCacheSession?.prefetch(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")
|
||||
} else {
|
||||
print("[DreamioVLC] jump seconds=\(seconds) target=\(nextTime) mode=direct")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
mediaPlayer.time = VLCTime(int: Int32(nextTime * 1000))
|
||||
#endif
|
||||
}
|
||||
|
|
@ -165,6 +230,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
|
||||
func stop() {
|
||||
#if canImport(MobileVLCKit)
|
||||
playbackStartupTask?.cancel()
|
||||
rangeCacheSession = nil
|
||||
mediaPlayer.stop()
|
||||
mediaPlayer.drawable = nil
|
||||
mediaPlayer.media = nil
|
||||
|
|
@ -269,6 +336,40 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
}
|
||||
|
||||
#if canImport(MobileVLCKit)
|
||||
private func startVLCMedia(
|
||||
url: URL,
|
||||
request: NativePlaybackRequest,
|
||||
playbackMode: String,
|
||||
cachingMilliseconds: Int,
|
||||
includeRemoteHTTPOptions: Bool
|
||||
) {
|
||||
let media = VLCMedia(url: url)
|
||||
media.addOption(":network-caching=\(cachingMilliseconds)")
|
||||
if includeRemoteHTTPOptions {
|
||||
media.addOption(":http-reconnect")
|
||||
addRemoteHeaders(to: media, request: request)
|
||||
}
|
||||
|
||||
mediaPlayer.media = media
|
||||
#if DEBUG
|
||||
print("[DreamioVLC] opening mode=\(playbackMode) cachingMs=\(cachingMilliseconds) url=\(URLRedactor.redactedURLString(url.absoluteString))")
|
||||
#endif
|
||||
mediaPlayer.play()
|
||||
}
|
||||
|
||||
private func addRemoteHeaders(to media: VLCMedia, request: NativePlaybackRequest) {
|
||||
let headerValue = request.headers
|
||||
.map { "\($0.key): \($0.value)" }
|
||||
.joined(separator: "\r\n")
|
||||
media.addOption(":http-referrer=\(request.referer)")
|
||||
if let userAgent = request.userAgent {
|
||||
media.addOption(":http-user-agent=\(userAgent)")
|
||||
}
|
||||
if !headerValue.isEmpty {
|
||||
media.addOption(":http-header=\(headerValue)")
|
||||
}
|
||||
}
|
||||
|
||||
private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {
|
||||
var attachedCount = 0
|
||||
var duplicateCount = 0
|
||||
|
|
@ -430,7 +531,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
|
||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
||||
#if DEBUG
|
||||
print("[DreamioVLC] state=\(stateName(mediaPlayer.state))")
|
||||
logPlaybackStateIfNeeded(stateName(mediaPlayer.state))
|
||||
#endif
|
||||
switch mediaPlayer.state {
|
||||
case .buffering, .playing:
|
||||
|
|
@ -477,5 +578,24 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
|
|||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func logPlaybackStateIfNeeded(_ state: String) {
|
||||
if state == "buffering" {
|
||||
let now = Date()
|
||||
if lastLoggedState == state,
|
||||
let lastBufferingLogTime,
|
||||
now.timeIntervalSince(lastBufferingLogTime) < 2 {
|
||||
return
|
||||
}
|
||||
lastBufferingLogTime = now
|
||||
}
|
||||
|
||||
if lastLoggedState != state || state == "buffering" {
|
||||
print("[DreamioVLC] state=\(state)")
|
||||
lastLoggedState = state
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ struct StreamResolverTests {
|
|||
testSubtitleDisplayNameNormalization()
|
||||
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
|
||||
testSubtitleOptionMappingIncludesNone()
|
||||
testContentRangeParsing()
|
||||
testSparseRangeStoreMergesOverlaps()
|
||||
testSparseRangeStoreHitPartialHitAndMiss()
|
||||
testSparseRangeStoreEvictsOutsideWindow()
|
||||
await testRangeProbeFallsBackWhenServerIgnoresRange()
|
||||
await testRangeFetcherPreservesHeaders()
|
||||
print("StreamResolverTests passed")
|
||||
}
|
||||
|
||||
|
|
@ -267,6 +273,107 @@ struct StreamResolverTests {
|
|||
assert(SubtitleResolver.isDirectSubtitleFile(candidates[0].url), "Expected Stremio subtitle downloads to be attachable without another resolver hop")
|
||||
}
|
||||
|
||||
private static func testContentRangeParsing() {
|
||||
let parsed = HTTPContentRange.parse("bytes 10-19/100")
|
||||
|
||||
assertEqual(parsed?.range.start, 10)
|
||||
assertEqual(parsed?.range.end, 19)
|
||||
assertEqual(parsed?.totalLength, 100)
|
||||
assert(HTTPContentRange.parse("items 10-19/100") == nil, "Expected non-byte content range to be rejected")
|
||||
assert(HTTPContentRange.parse("bytes 20-10/100") == nil, "Expected invalid content range to be rejected")
|
||||
}
|
||||
|
||||
private static func testSparseRangeStoreMergesOverlaps() {
|
||||
let store = SparseHTTPByteRangeStore()
|
||||
|
||||
store.insert(data: Data([0, 1, 2, 3]), at: 0)
|
||||
store.insert(data: Data([3, 4, 5]), at: 3)
|
||||
|
||||
assertEqual(store.cachedRanges, [HTTPByteRange(start: 0, end: 5)])
|
||||
assertEqual(Array(store.data(for: HTTPByteRange(start: 0, end: 5)) ?? Data()), [0, 1, 2, 3, 4, 5])
|
||||
}
|
||||
|
||||
private static func testSparseRangeStoreHitPartialHitAndMiss() {
|
||||
let store = SparseHTTPByteRangeStore()
|
||||
|
||||
store.insert(data: Data([10, 11, 12, 13]), at: 10)
|
||||
|
||||
assertEqual(Array(store.data(for: HTTPByteRange(start: 10, end: 13)) ?? Data()), [10, 11, 12, 13])
|
||||
assert(store.data(for: HTTPByteRange(start: 11, end: 14)) == nil, "Expected partial hit to miss")
|
||||
assert(store.data(for: HTTPByteRange(start: 20, end: 21)) == nil, "Expected uncached range to miss")
|
||||
}
|
||||
|
||||
private static func testSparseRangeStoreEvictsOutsideWindow() {
|
||||
let store = SparseHTTPByteRangeStore()
|
||||
|
||||
store.insert(data: Data([0, 1, 2]), at: 0)
|
||||
store.insert(data: Data([10, 11, 12]), at: 10)
|
||||
store.evict(keeping: HTTPByteRange(start: 9, end: 12))
|
||||
|
||||
assertEqual(store.cachedRanges, [HTTPByteRange(start: 10, end: 12)])
|
||||
}
|
||||
|
||||
private static func testRangeProbeFallsBackWhenServerIgnoresRange() async {
|
||||
MockURLProtocol.handler = { request in
|
||||
if request.httpMethod == "HEAD" {
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: ["Content-Length": "4"]
|
||||
)!
|
||||
return (Data(), response)
|
||||
}
|
||||
assertEqual(request.value(forHTTPHeaderField: "Range"), "bytes=0-0")
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 200,
|
||||
httpVersion: nil,
|
||||
headerFields: ["Content-Length": "4"]
|
||||
)!
|
||||
return (Data([1, 2, 3, 4]), response)
|
||||
}
|
||||
|
||||
let fetcher = HTTPRangeRemoteFetcher(
|
||||
url: URL(string: "https://cdn.example.test/movie.mp4")!,
|
||||
headers: [:],
|
||||
session: mockSession()
|
||||
)
|
||||
let probe = await fetcher.probe()
|
||||
|
||||
assertEqual(probe.isCacheable, false)
|
||||
assertEqual(probe.fallbackReason, "range-probe-status-200")
|
||||
}
|
||||
|
||||
private static func testRangeFetcherPreservesHeaders() async {
|
||||
MockURLProtocol.handler = { request in
|
||||
assertEqual(request.value(forHTTPHeaderField: "User-Agent"), "DreamioTest/1")
|
||||
assertEqual(request.value(forHTTPHeaderField: "Referer"), "https://web.stremio.com/")
|
||||
assertEqual(request.value(forHTTPHeaderField: "Cookie"), "session=abc")
|
||||
assertEqual(request.value(forHTTPHeaderField: "Range"), "bytes=5-7")
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!,
|
||||
statusCode: 206,
|
||||
httpVersion: nil,
|
||||
headerFields: ["Content-Range": "bytes 5-7/20"]
|
||||
)!
|
||||
return (Data([5, 6, 7]), response)
|
||||
}
|
||||
|
||||
let fetcher = HTTPRangeRemoteFetcher(
|
||||
url: URL(string: "https://cdn.example.test/movie.mp4")!,
|
||||
headers: [
|
||||
"User-Agent": "DreamioTest/1",
|
||||
"Referer": "https://web.stremio.com/",
|
||||
"Cookie": "session=abc"
|
||||
],
|
||||
session: mockSession()
|
||||
)
|
||||
let data = try? await fetcher.fetch(range: HTTPByteRange(start: 5, end: 7))
|
||||
|
||||
assertEqual(Array(data ?? Data()), [5, 6, 7])
|
||||
}
|
||||
|
||||
private static func testOpenSubtitlesV3DownloadResponseResolution() {
|
||||
let payload = """
|
||||
{
|
||||
|
|
@ -517,6 +624,7 @@ struct StreamResolverTests {
|
|||
}
|
||||
|
||||
private final class MockURLProtocol: URLProtocol {
|
||||
static var handler: ((URLRequest) throws -> (Data, HTTPURLResponse))?
|
||||
static var handlers: [String: (status: Int, url: URL, data: Data)] = [:]
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool {
|
||||
|
|
@ -528,6 +636,18 @@ private final class MockURLProtocol: URLProtocol {
|
|||
}
|
||||
|
||||
override func startLoading() {
|
||||
if let handler = Self.handler {
|
||||
do {
|
||||
let (data, response) = try handler(request)
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
} catch {
|
||||
client?.urlProtocol(self, didFailWithError: error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = request.url,
|
||||
let handler = Self.handlers[url.absoluteString],
|
||||
let response = HTTPURLResponse(
|
||||
|
|
|
|||
296
docs/turns/2026-05-25-vlc-local-range-cache.html
Normal file
296
docs/turns/2026-05-25-vlc-local-range-cache.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