mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
add native stream seek cache proxy
This commit is contained in:
parent
4815c3a7f6
commit
bccae25937
8 changed files with 849 additions and 2 deletions
|
|
@ -38,3 +38,4 @@
|
||||||
{"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-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-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-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."}}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,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-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-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-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-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-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}
|
{"_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 */; };
|
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */; };
|
||||||
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */; };
|
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */; };
|
||||||
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B512C00100100DREAMIO /* StreamResolver.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 /* NativeStreamCacheProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B532C00100100DREAMIO /* NativeStreamCacheProxy.swift */; };
|
||||||
|
B6C42C187A771A50D200AD84 /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
|
@ -29,6 +30,7 @@
|
||||||
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCNativePlaybackBackend.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.swift; sourceTree = "<group>"; };
|
||||||
|
6F2A2B532C00100100DREAMIO /* NativeStreamCacheProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeStreamCacheProxy.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>"; };
|
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; };
|
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>"; };
|
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;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
B6C42C187A771A50D200AD84 /* Pods_Dreamio.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
@ -91,6 +94,7 @@
|
||||||
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,
|
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,
|
||||||
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,
|
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,
|
||||||
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,
|
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,
|
||||||
|
6F2A2B532C00100100DREAMIO /* NativeStreamCacheProxy.swift */,
|
||||||
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,
|
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,
|
||||||
6F2A2B392C00100100DREAMIO /* Info.plist */,
|
6F2A2B392C00100100DREAMIO /* Info.plist */,
|
||||||
);
|
);
|
||||||
|
|
@ -237,6 +241,7 @@
|
||||||
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */,
|
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */,
|
||||||
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */,
|
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */,
|
||||||
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */,
|
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */,
|
||||||
|
6F2A2B522C00100100DREAMIO /* NativeStreamCacheProxy.swift in Sources */,
|
||||||
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */,
|
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ final class NativePlayerViewController: UIViewController {
|
||||||
private var attachedSubtitleURLs: Set<URL>
|
private var attachedSubtitleURLs: Set<URL>
|
||||||
private var audioMenuSignature: String?
|
private var audioMenuSignature: String?
|
||||||
private var captionsMenuSignature: String?
|
private var captionsMenuSignature: String?
|
||||||
|
private var streamCacheProxy: NativeStreamCacheProxy?
|
||||||
var onDismiss: (() -> Void)?
|
var onDismiss: (() -> Void)?
|
||||||
|
|
||||||
private let loadingView: UIActivityIndicatorView = {
|
private let loadingView: UIActivityIndicatorView = {
|
||||||
|
|
@ -137,7 +138,8 @@ final class NativePlayerViewController: UIViewController {
|
||||||
configureBackend()
|
configureBackend()
|
||||||
configureLayout()
|
configureLayout()
|
||||||
startStartupTimer()
|
startStartupTimer()
|
||||||
backend.play(request: request)
|
let playbackRequest = startProxyPlaybackRequest(for: request)
|
||||||
|
backend.play(request: playbackRequest)
|
||||||
addSubtitleCandidates(request.subtitleCandidates)
|
addSubtitleCandidates(request.subtitleCandidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,9 +194,28 @@ final class NativePlayerViewController: UIViewController {
|
||||||
controlsTimer?.invalidate()
|
controlsTimer?.invalidate()
|
||||||
progressTimer?.invalidate()
|
progressTimer?.invalidate()
|
||||||
backend.stop()
|
backend.stop()
|
||||||
|
streamCacheProxy?.stop()
|
||||||
|
streamCacheProxy = nil
|
||||||
onDismiss?()
|
onDismiss?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startProxyPlaybackRequest(for request: NativePlaybackRequest) -> NativePlaybackRequest {
|
||||||
|
guard request.playbackURL.scheme?.lowercased().hasPrefix("http") == true else {
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
let proxy = NativeStreamCacheProxy(session: NativeStreamCacheProxy.Session(request: request))
|
||||||
|
do {
|
||||||
|
let proxyURL = try proxy.start()
|
||||||
|
streamCacheProxy = proxy
|
||||||
|
return request.withPlaybackURL(proxyURL)
|
||||||
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioStreamProxy] start-failed error=\(error.localizedDescription)")
|
||||||
|
#endif
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func resolveSubtitleCandidates(_ candidates: [SubtitleCandidate]) async -> [SubtitleCandidate] {
|
private func resolveSubtitleCandidates(_ candidates: [SubtitleCandidate]) async -> [SubtitleCandidate] {
|
||||||
var resolved: [SubtitleCandidate] = []
|
var resolved: [SubtitleCandidate] = []
|
||||||
for candidate in candidates {
|
for candidate in candidates {
|
||||||
|
|
|
||||||
399
Dreamio/NativeStreamCacheProxy.swift
Normal file
399
Dreamio/NativeStreamCacheProxy.swift
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
struct HTTPRange: Equatable {
|
||||||
|
let start: Int64
|
||||||
|
let end: Int64?
|
||||||
|
|
||||||
|
var headerValue: String {
|
||||||
|
if let end {
|
||||||
|
return "bytes=\(start)-\(end)"
|
||||||
|
}
|
||||||
|
return "bytes=\(start)-"
|
||||||
|
}
|
||||||
|
|
||||||
|
var length: Int64? {
|
||||||
|
guard let end else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return max(0, end - start + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parse(_ value: String?) -> HTTPRange? {
|
||||||
|
guard let value else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard trimmed.lowercased().hasPrefix("bytes=") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let spec = String(trimmed.dropFirst(6))
|
||||||
|
guard !spec.contains(","), let dash = spec.firstIndex(of: "-") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let startPart = spec[..<dash]
|
||||||
|
let endPart = spec[spec.index(after: dash)...]
|
||||||
|
guard !startPart.isEmpty, let start = Int64(startPart), start >= 0 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if endPart.isEmpty {
|
||||||
|
return HTTPRange(start: start, end: nil)
|
||||||
|
}
|
||||||
|
guard let end = Int64(endPart), end >= start else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return HTTPRange(start: start, end: end)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func contentRange(start: Int64, end: Int64, totalLength: Int64?) -> String {
|
||||||
|
let total = totalLength.map(String.init) ?? "*"
|
||||||
|
return "bytes \(start)-\(end)/\(total)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class CachedRangeStore {
|
||||||
|
struct Lookup {
|
||||||
|
let data: Data
|
||||||
|
let isComplete: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Chunk {
|
||||||
|
let start: Int64
|
||||||
|
let end: Int64
|
||||||
|
let fileURL: URL
|
||||||
|
var lastAccess: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private let directory: URL
|
||||||
|
private let byteBudget: Int64
|
||||||
|
private let fileManager: FileManager
|
||||||
|
private var chunks: [Chunk] = []
|
||||||
|
|
||||||
|
init(sessionID: String, byteBudget: Int64, fileManager: FileManager = .default) {
|
||||||
|
self.byteBudget = byteBudget
|
||||||
|
self.fileManager = fileManager
|
||||||
|
directory = fileManager.temporaryDirectory
|
||||||
|
.appendingPathComponent("DreamioNativeStreamCache", isDirectory: true)
|
||||||
|
.appendingPathComponent(sessionID, isDirectory: true)
|
||||||
|
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookup(range: HTTPRange, maximumLength: Int64) -> Lookup? {
|
||||||
|
let requestedEnd = range.end ?? range.start + maximumLength - 1
|
||||||
|
var cursor = range.start
|
||||||
|
var data = Data()
|
||||||
|
var touchedIndexes = Set<Int>()
|
||||||
|
|
||||||
|
for (index, chunk) in chunks.sorted(by: { $0.start < $1.start }).enumerated() {
|
||||||
|
guard chunk.end >= cursor, chunk.start <= cursor else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let readStart = max(cursor, chunk.start)
|
||||||
|
let readEnd = min(requestedEnd, chunk.end)
|
||||||
|
guard readEnd >= readStart,
|
||||||
|
let chunkData = try? Data(contentsOf: chunk.fileURL) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let lower = Int(readStart - chunk.start)
|
||||||
|
let upper = Int(readEnd - chunk.start + 1)
|
||||||
|
data.append(chunkData.subdata(in: lower..<upper))
|
||||||
|
cursor = readEnd + 1
|
||||||
|
touchedIndexes.insert(index)
|
||||||
|
if cursor > requestedEnd {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !data.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let now = Date()
|
||||||
|
for index in touchedIndexes where chunks.indices.contains(index) {
|
||||||
|
chunks[index].lastAccess = now
|
||||||
|
}
|
||||||
|
return Lookup(data: data, isComplete: cursor > requestedEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func store(data: Data, start: Int64) {
|
||||||
|
guard !data.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let end = start + Int64(data.count) - 1
|
||||||
|
let fileURL = directory.appendingPathComponent("\(start)-\(end).chunk")
|
||||||
|
do {
|
||||||
|
try data.write(to: fileURL, options: .atomic)
|
||||||
|
chunks.append(Chunk(start: start, end: end, fileURL: fileURL, lastAccess: Date()))
|
||||||
|
chunks.sort { $0.start < $1.start }
|
||||||
|
evictIfNeeded()
|
||||||
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioStreamProxy] cache-store-failed range=\(start)-\(end) error=\(error.localizedDescription)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func evictKeepingBytesNear(offset: Int64) {
|
||||||
|
let lowerBound = max(0, offset - byteBudget)
|
||||||
|
let removed = chunks.filter { $0.end < lowerBound }
|
||||||
|
chunks.removeAll { $0.end < lowerBound }
|
||||||
|
removed.forEach { try? fileManager.removeItem(at: $0.fileURL) }
|
||||||
|
#if DEBUG
|
||||||
|
if !removed.isEmpty {
|
||||||
|
print("[DreamioStreamProxy] eviction removed=\(removed.count) lowerBound=\(lowerBound)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
evictIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAll() {
|
||||||
|
try? fileManager.removeItem(at: directory)
|
||||||
|
chunks.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func evictIfNeeded() {
|
||||||
|
var total = chunks.reduce(Int64(0)) { $0 + ($1.end - $1.start + 1) }
|
||||||
|
guard total > byteBudget else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for chunk in chunks.sorted(by: { $0.lastAccess < $1.lastAccess }) {
|
||||||
|
guard total > byteBudget else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
try? fileManager.removeItem(at: chunk.fileURL)
|
||||||
|
chunks.removeAll { $0.fileURL == chunk.fileURL }
|
||||||
|
total -= chunk.end - chunk.start + 1
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioStreamProxy] eviction range=\(chunk.start)-\(chunk.end) remainingBytes=\(total)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class NativeStreamCacheProxy {
|
||||||
|
struct Session {
|
||||||
|
let id: String
|
||||||
|
let upstreamURL: URL
|
||||||
|
let headers: [String: String]
|
||||||
|
let referer: String
|
||||||
|
let userAgent: String?
|
||||||
|
let estimatedBitrate: Int64?
|
||||||
|
|
||||||
|
init(request: NativePlaybackRequest) {
|
||||||
|
id = UUID().uuidString
|
||||||
|
upstreamURL = request.playbackURL
|
||||||
|
headers = request.headers
|
||||||
|
referer = request.referer
|
||||||
|
userAgent = request.userAgent
|
||||||
|
estimatedBitrate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct UpstreamResponse {
|
||||||
|
let statusCode: Int
|
||||||
|
let headers: [AnyHashable: Any]
|
||||||
|
let data: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
private let session: Session
|
||||||
|
private let store: CachedRangeStore
|
||||||
|
private let fetchLength: Int64
|
||||||
|
private let prefetchLength: Int64
|
||||||
|
private var listener: NWListener?
|
||||||
|
private let queue = DispatchQueue(label: "dreamio.native-stream-cache-proxy")
|
||||||
|
|
||||||
|
init(session: Session, byteBudget: Int64? = nil, fetchLength: Int64 = 8 * 1024 * 1024) {
|
||||||
|
self.session = session
|
||||||
|
self.fetchLength = fetchLength
|
||||||
|
prefetchLength = fetchLength
|
||||||
|
let budget = byteBudget ?? max(30 * 1024 * 1024, (session.estimatedBitrate ?? 0) * 30 / 8)
|
||||||
|
store = CachedRangeStore(sessionID: session.id, byteBudget: budget)
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() throws -> URL {
|
||||||
|
let listener = try NWListener(using: .tcp, on: .any)
|
||||||
|
self.listener = listener
|
||||||
|
listener.newConnectionHandler = { [weak self] connection in
|
||||||
|
self?.handle(connection: connection)
|
||||||
|
}
|
||||||
|
listener.start(queue: queue)
|
||||||
|
guard let port = listener.port else {
|
||||||
|
throw URLError(.cannotConnectToHost)
|
||||||
|
}
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioStreamProxy] start port=\(port.rawValue) upstream=\(URLRedactor.redactedURLString(session.upstreamURL.absoluteString))")
|
||||||
|
#endif
|
||||||
|
return URL(string: "http://127.0.0.1:\(port.rawValue)/stream/\(session.id)")!
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioStreamProxy] stop session=\(session.id)")
|
||||||
|
#endif
|
||||||
|
listener?.cancel()
|
||||||
|
listener = nil
|
||||||
|
store.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func upstreamRequest(for range: HTTPRange?) -> URLRequest {
|
||||||
|
var request = URLRequest(url: session.upstreamURL)
|
||||||
|
for (key, value) in session.headers {
|
||||||
|
request.setValue(value, forHTTPHeaderField: key)
|
||||||
|
}
|
||||||
|
request.setValue(session.referer, forHTTPHeaderField: "Referer")
|
||||||
|
if let userAgent = session.userAgent {
|
||||||
|
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
|
||||||
|
}
|
||||||
|
if let range {
|
||||||
|
request.setValue(range.headerValue, forHTTPHeaderField: "Range")
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
static func responseStatusForUpstreamStatus(_ statusCode: Int) -> Int {
|
||||||
|
statusCode == 206 ? 206 : statusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(connection: NWConnection) {
|
||||||
|
connection.start(queue: queue)
|
||||||
|
connection.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) { [weak self] data, _, _, _ in
|
||||||
|
guard let self, let data, let request = HTTPRequest(data: data) else {
|
||||||
|
connection.cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let range = HTTPRange.parse(request.headers["range"])
|
||||||
|
self.respond(to: range, on: connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func respond(to requestedRange: HTTPRange?, on connection: NWConnection) {
|
||||||
|
let range = requestedRange ?? HTTPRange(start: 0, end: fetchLength - 1)
|
||||||
|
let maximumLength = range.length ?? fetchLength
|
||||||
|
if let lookup = store.lookup(range: range, maximumLength: maximumLength), lookup.isComplete {
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioStreamProxy] range-hit range=\(range.headerValue) bytes=\(lookup.data.count)")
|
||||||
|
#endif
|
||||||
|
send(data: lookup.data, statusCode: 206, rangeStart: range.start, totalLength: nil, contentType: nil, on: connection)
|
||||||
|
prefetch(after: range.start + Int64(lookup.data.count))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioStreamProxy] range-miss range=\(range.headerValue)")
|
||||||
|
#endif
|
||||||
|
let fetchRange = HTTPRange(start: range.start, end: range.start + maximumLength - 1)
|
||||||
|
guard let response = fetch(range: fetchRange) else {
|
||||||
|
sendStatus(502, on: connection)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioStreamProxy] upstream status=\(response.statusCode) range=\(fetchRange.headerValue)")
|
||||||
|
#endif
|
||||||
|
let responseStatus = Self.responseStatusForUpstreamStatus(response.statusCode)
|
||||||
|
if responseStatus == 206 {
|
||||||
|
store.store(data: response.data, start: range.start)
|
||||||
|
store.evictKeepingBytesNear(offset: range.start)
|
||||||
|
let contentType = response.headers["Content-Type"] as? String
|
||||||
|
let totalLength = totalLength(from: response.headers["Content-Range"] as? String)
|
||||||
|
send(data: response.data, statusCode: 206, rangeStart: range.start, totalLength: totalLength, contentType: contentType, on: connection)
|
||||||
|
prefetch(after: range.start + Int64(response.data.count))
|
||||||
|
} else {
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioStreamProxy] pass-through fallback reason=upstream-returned-\(response.statusCode)")
|
||||||
|
#endif
|
||||||
|
send(data: response.data, statusCode: response.statusCode, headers: response.headers, on: connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prefetch(after offset: Int64) {
|
||||||
|
let range = HTTPRange(start: offset, end: offset + prefetchLength - 1)
|
||||||
|
queue.async { [weak self] in
|
||||||
|
guard let self, self.store.lookup(range: range, maximumLength: self.prefetchLength)?.isComplete != true else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let response = self.fetch(range: range), response.statusCode == 206 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.store.store(data: response.data, start: offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetch(range: HTTPRange) -> UpstreamResponse? {
|
||||||
|
let semaphore = DispatchSemaphore(value: 0)
|
||||||
|
var result: UpstreamResponse?
|
||||||
|
URLSession.shared.dataTask(with: upstreamRequest(for: range)) { data, response, _ in
|
||||||
|
if let http = response as? HTTPURLResponse, let data {
|
||||||
|
result = UpstreamResponse(statusCode: http.statusCode, headers: http.allHeaderFields, data: data)
|
||||||
|
}
|
||||||
|
semaphore.signal()
|
||||||
|
}.resume()
|
||||||
|
semaphore.wait()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(data: Data, statusCode: Int, rangeStart: Int64, totalLength: Int64?, contentType: String?, on connection: NWConnection) {
|
||||||
|
let end = rangeStart + Int64(data.count) - 1
|
||||||
|
var headers = [
|
||||||
|
"Content-Length": "\(data.count)",
|
||||||
|
"Content-Range": HTTPRange.contentRange(start: rangeStart, end: end, totalLength: totalLength),
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Connection": "close"
|
||||||
|
]
|
||||||
|
if let contentType {
|
||||||
|
headers["Content-Type"] = contentType
|
||||||
|
}
|
||||||
|
send(statusCode: statusCode, headers: headers, body: data, on: connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(data: Data, statusCode: Int, headers upstreamHeaders: [AnyHashable: Any], on connection: NWConnection) {
|
||||||
|
var headers = [
|
||||||
|
"Content-Length": "\(data.count)",
|
||||||
|
"Accept-Ranges": "none",
|
||||||
|
"Connection": "close"
|
||||||
|
]
|
||||||
|
if let contentType = upstreamHeaders["Content-Type"] as? String {
|
||||||
|
headers["Content-Type"] = contentType
|
||||||
|
}
|
||||||
|
send(statusCode: statusCode, headers: headers, body: data, on: connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendStatus(_ statusCode: Int, on connection: NWConnection) {
|
||||||
|
send(statusCode: statusCode, headers: ["Content-Length": "0", "Connection": "close"], body: Data(), on: connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(statusCode: Int, headers: [String: String], body: Data, on connection: NWConnection) {
|
||||||
|
let reason = statusCode == 206 ? "Partial Content" : statusCode == 200 ? "OK" : "Bad Gateway"
|
||||||
|
let headerLines = headers.map { "\($0.key): \($0.value)" }.joined(separator: "\r\n")
|
||||||
|
var response = Data("HTTP/1.1 \(statusCode) \(reason)\r\n\(headerLines)\r\n\r\n".utf8)
|
||||||
|
response.append(body)
|
||||||
|
connection.send(content: response, completion: .contentProcessed { _ in connection.cancel() })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func totalLength(from contentRange: String?) -> Int64? {
|
||||||
|
guard let contentRange, let slash = contentRange.lastIndex(of: "/") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let value = contentRange[contentRange.index(after: slash)...]
|
||||||
|
return Int64(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct HTTPRequest {
|
||||||
|
let headers: [String: String]
|
||||||
|
|
||||||
|
init?(data: Data) {
|
||||||
|
guard let string = String(data: data, encoding: .utf8),
|
||||||
|
let headerBlock = string.components(separatedBy: "\r\n\r\n").first else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var headers: [String: String] = [:]
|
||||||
|
for line in headerBlock.components(separatedBy: "\r\n").dropFirst() {
|
||||||
|
guard let separator = line.firstIndex(of: ":") else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let key = line[..<separator].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let value = line[line.index(after: separator)...].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
headers[key] = value
|
||||||
|
}
|
||||||
|
self.headers = headers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,20 @@ struct NativePlaybackRequest {
|
||||||
let headers: [String: String]
|
let headers: [String: String]
|
||||||
let classification: StreamClassification
|
let classification: StreamClassification
|
||||||
let subtitleCandidates: [SubtitleCandidate]
|
let subtitleCandidates: [SubtitleCandidate]
|
||||||
|
|
||||||
|
func withPlaybackURL(_ playbackURL: URL) -> NativePlaybackRequest {
|
||||||
|
NativePlaybackRequest(
|
||||||
|
playbackURL: playbackURL,
|
||||||
|
observedURL: observedURL,
|
||||||
|
resolverURL: resolverURL,
|
||||||
|
pageURL: pageURL,
|
||||||
|
userAgent: userAgent,
|
||||||
|
referer: referer,
|
||||||
|
headers: headers,
|
||||||
|
classification: classification,
|
||||||
|
subtitleCandidates: subtitleCandidates
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SubtitleCandidate: Equatable {
|
struct SubtitleCandidate: Equatable {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,12 @@ struct StreamResolverTests {
|
||||||
testSubtitleDisplayNameNormalization()
|
testSubtitleDisplayNameNormalization()
|
||||||
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
|
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
|
||||||
testSubtitleOptionMappingIncludesNone()
|
testSubtitleOptionMappingIncludesNone()
|
||||||
|
testHTTPRangeParsing()
|
||||||
|
testContentRangeFormatting()
|
||||||
|
testCacheLookupAcrossChunkBoundaries()
|
||||||
|
testCacheEvictionOutsideByteBudget()
|
||||||
|
testProxyForwardsUpstreamHeaders()
|
||||||
|
testProxyPassThroughFallbackStatus()
|
||||||
print("StreamResolverTests passed")
|
print("StreamResolverTests passed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,6 +46,85 @@ struct StreamResolverTests {
|
||||||
assertEqual(request.headers["User-Agent"], "DreamioTest/1")
|
assertEqual(request.headers["User-Agent"], "DreamioTest/1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func testHTTPRangeParsing() {
|
||||||
|
assertEqual(HTTPRange.parse("bytes=10-20"), HTTPRange(start: 10, end: 20))
|
||||||
|
assertEqual(HTTPRange.parse("bytes=10-"), HTTPRange(start: 10, end: nil))
|
||||||
|
assert(HTTPRange.parse("items=10-20") == nil, "Expected non-byte range to be rejected")
|
||||||
|
assert(HTTPRange.parse("bytes=-20") == nil, "Expected suffix ranges to be rejected for v1")
|
||||||
|
assert(HTTPRange.parse("bytes=20-10") == nil, "Expected inverted ranges to be rejected")
|
||||||
|
assert(HTTPRange.parse("bytes=1-2,3-4") == nil, "Expected multipart ranges to be rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func testContentRangeFormatting() {
|
||||||
|
assertEqual(HTTPRange.contentRange(start: 10, end: 20, totalLength: 100), "bytes 10-20/100")
|
||||||
|
assertEqual(HTTPRange.contentRange(start: 10, end: 20, totalLength: nil), "bytes 10-20/*")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func testCacheLookupAcrossChunkBoundaries() {
|
||||||
|
let store = CachedRangeStore(sessionID: "test-\(UUID().uuidString)", byteBudget: 1024)
|
||||||
|
defer { store.removeAll() }
|
||||||
|
store.store(data: Data("abc".utf8), start: 0)
|
||||||
|
store.store(data: Data("def".utf8), start: 3)
|
||||||
|
|
||||||
|
let lookup = store.lookup(range: HTTPRange(start: 0, end: 5), maximumLength: 6)
|
||||||
|
|
||||||
|
assertEqual(String(data: lookup?.data ?? Data(), encoding: .utf8), "abcdef")
|
||||||
|
assertEqual(lookup?.isComplete, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func testCacheEvictionOutsideByteBudget() {
|
||||||
|
let store = CachedRangeStore(sessionID: "test-\(UUID().uuidString)", byteBudget: 6)
|
||||||
|
defer { store.removeAll() }
|
||||||
|
store.store(data: Data("abcdef".utf8), start: 0)
|
||||||
|
store.store(data: Data("ghijkl".utf8), start: 6)
|
||||||
|
|
||||||
|
let oldLookup = store.lookup(range: HTTPRange(start: 0, end: 5), maximumLength: 6)
|
||||||
|
let newLookup = store.lookup(range: HTTPRange(start: 6, end: 11), maximumLength: 6)
|
||||||
|
|
||||||
|
assert(oldLookup == nil, "Expected old chunk to be evicted outside the byte budget")
|
||||||
|
assertEqual(String(data: newLookup?.data ?? Data(), encoding: .utf8), "ghijkl")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func testProxyForwardsUpstreamHeaders() {
|
||||||
|
let proxy = NativeStreamCacheProxy(session: NativeStreamCacheProxy.Session(request: proxyTestRequest()))
|
||||||
|
let upstreamRequest = proxy.upstreamRequest(for: HTTPRange(start: 12, end: 34))
|
||||||
|
|
||||||
|
assertEqual(upstreamRequest.value(forHTTPHeaderField: "Range"), "bytes=12-34")
|
||||||
|
assertEqual(upstreamRequest.value(forHTTPHeaderField: "Referer"), "https://resolver.example.test/")
|
||||||
|
assertEqual(upstreamRequest.value(forHTTPHeaderField: "User-Agent"), "DreamioTest/1")
|
||||||
|
assertEqual(upstreamRequest.value(forHTTPHeaderField: "Authorization"), "Bearer secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func testProxyPassThroughFallbackStatus() {
|
||||||
|
assertEqual(NativeStreamCacheProxy.responseStatusForUpstreamStatus(206), 206)
|
||||||
|
assertEqual(NativeStreamCacheProxy.responseStatusForUpstreamStatus(200), 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func proxyTestRequest() -> NativePlaybackRequest {
|
||||||
|
NativePlaybackRequest(
|
||||||
|
playbackURL: URL(string: "https://cdn.example.test/movie.mkv")!,
|
||||||
|
observedURL: URL(string: "https://cdn.example.test/movie.mkv")!,
|
||||||
|
resolverURL: URL(string: "https://resolver.example.test/play")!,
|
||||||
|
pageURL: nil,
|
||||||
|
userAgent: "DreamioTest/1",
|
||||||
|
referer: "https://resolver.example.test/",
|
||||||
|
headers: [
|
||||||
|
"Referer": "https://resolver.example.test/",
|
||||||
|
"User-Agent": "DreamioTest/1",
|
||||||
|
"Authorization": "Bearer secret"
|
||||||
|
],
|
||||||
|
classification: StreamClassification(
|
||||||
|
sourceKind: .directFile,
|
||||||
|
containerGuess: .mkv,
|
||||||
|
reason: "test",
|
||||||
|
shouldIntercept: true,
|
||||||
|
sanitizedObservedURL: "https://cdn.example.test/movie.mkv",
|
||||||
|
sanitizedResolverURL: nil
|
||||||
|
),
|
||||||
|
subtitleCandidates: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private static func testResolverSelectsUnsupportedDirectURLAndHeaders() {
|
private static func testResolverSelectsUnsupportedDirectURLAndHeaders() {
|
||||||
let payload: [String: Any] = [
|
let payload: [String: Any] = [
|
||||||
"streams": [
|
"streams": [
|
||||||
|
|
|
||||||
321
docs/turns/2026-05-25-local-seek-buffer-vlc-playback.html
Normal file
321
docs/turns/2026-05-25-local-seek-buffer-vlc-playback.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