mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
add remote stremio server support
This commit is contained in:
parent
e7ddd6d755
commit
592dc12970
8 changed files with 1247 additions and 2 deletions
|
|
@ -45,3 +45,4 @@
|
|||
{"id":"int-c55d4a7b","kind":"field_change","created_at":"2026-05-27T04:20:08.84278Z","actor":"dirtydishes","issue_id":"dreamio-e2q","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by gating native startup loading until VLC readiness and startup subtitle candidate processing both complete."}}
|
||||
{"id":"int-bfb2d962","kind":"field_change","created_at":"2026-05-27T04:30:15.810274Z","actor":"dirtydishes","issue_id":"dreamio-69r","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented AVAudioSession warm-up around native VLC pause/resume and validated simulator build."}}
|
||||
{"id":"int-b32c40f0","kind":"field_change","created_at":"2026-05-27T06:14:56.320425Z","actor":"dirtydishes","issue_id":"dreamio-0pi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented MobileVLCKit resume sync changes: removed repeated resume seek-holding, simplified streaming cache options, retained DEBUG resume observation, updated tests, and documented the turn. Real-device validation is tracked in dreamio-yny."}}
|
||||
{"id":"int-1b7a4753","kind":"field_change","created_at":"2026-05-27T17:03:31.966463Z","actor":"dirtydishes","issue_id":"dreamio-dc6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented optional user-configured Remote Stremio Server support with URL validation, Stremio Web streamingServerUrl injection, connection testing, docs, tests, and follow-up issue dreamio-8i5 for real-device media validation."}}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
{"_type":"issue","id":"dreamio-btc","title":"Bound VLC range cache probe startup latency","description":"After enabling MKV range cache probing, some Torrentio/Real-Debrid MKV streams log cache-probe but never reach opening mode before the native-player startup timeout. Add a bounded probe/local-cache startup path that falls back to direct playback when the range probe is slow or inconclusive.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T12:14:02Z","created_by":"dirtydishes","updated_at":"2026-05-26T12:16:53Z","started_at":"2026-05-26T12:14:11Z","closed_at":"2026-05-26T12:16:53Z","close_reason":"Added a short timeout to range-cache probe requests so slow MKV HEAD/range probes fall back to direct VLC startup instead of tripping the native-player startup timeout.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-mun","title":"fix vlc cache loopback port startup","description":"Device logs showed local-cache playback opening http://127.0.0.1:0, because the NWListener ephemeral port was read before the listener reached ready. Wait for the real assigned port before returning the local cache URL.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:32:41Z","created_by":"dirtydishes","updated_at":"2026-05-25T22:33:15Z","started_at":"2026-05-25T22:33:14Z","closed_at":"2026-05-25T22:33:15Z","close_reason":"Wait for NWListener ready state before returning the local cache URL; verified tests and simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-8i5","title":"Validate remote Stremio Server playback on device","description":"Run Dreamio on real iPhone/iPad with a user-owned Stremio Server from server-docker and representative legal test streams. Capture whether HTTP/HLS/MP4/MKV/torrent paths are WebKit-playable, server-proxied/transcoded, or fall back to MobileVLCKit.","acceptance_criteria":"Document results for direct MP4, HLS, MKV with common codecs, MKV with unsupported audio/subtitles, and torrent-backed sample; note server version, TLS setup, Stremio Web behavior, WebKit result, and VLC fallback behavior.","status":"open","priority":1,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-27T16:56:57Z","created_by":"dirtydishes","updated_at":"2026-05-27T16:56:57Z","dependencies":[{"issue_id":"dreamio-8i5","depends_on_id":"dreamio-dc6","type":"blocks","created_at":"2026-05-27T12:57:04Z","created_by":"dirtydishes","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-yny","title":"Validate VLC resume audio sync on device","description":"Run manual real-device validation for MobileVLCKit direct-stream pause and resume after removing repeated seek-holding. Capture DEBUG resume-observation logs if audio still lags video.","acceptance_criteria":"Problematic direct-file streams are tested on a real iPhone or iPad; resume audio/video timing is recorded; DEBUG resume-observation logs are attached or summarized if lag persists.","status":"open","priority":1,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-27T06:14:11Z","created_by":"dirtydishes","updated_at":"2026-05-27T06:14:11Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-0pi","title":"Fix MobileVLCKit streaming resume audio sync","description":"Research MobileVLCKit streaming pause/resume behavior and adjust native VLC playback so audio does not lag behind video on every resume.","acceptance_criteria":"MobileVLCKit streaming resume behavior is researched; repeated resume seek-holding is removed or replaced; relevant tests pass; changes are documented.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T05:47:32Z","created_by":"dirtydishes","updated_at":"2026-05-27T06:14:56Z","started_at":"2026-05-27T05:47:35Z","closed_at":"2026-05-27T06:14:56Z","close_reason":"Implemented MobileVLCKit resume sync changes: removed repeated resume seek-holding, simplified streaming cache options, retained DEBUG resume observation, updated tests, and documented the turn. Real-device validation is tracked in dreamio-yny.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-69r","title":"Fix audio lag after native video resume","description":"Audio takes a moment to resume after pausing and playing native video; previous attempts did not resolve the lag.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T04:26:52Z","created_by":"dirtydishes","updated_at":"2026-05-27T04:30:16Z","started_at":"2026-05-27T04:26:56Z","closed_at":"2026-05-27T04:30:16Z","close_reason":"Implemented AVAudioSession warm-up around native VLC pause/resume and validated simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
|
@ -50,6 +51,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-dc6","title":"Add optional remote Stremio Server support","description":"Implement a power-user Dreamio setting for user-supplied Stremio Server URLs, wire hosted Stremio Web to that URL without hardcoded service addresses, add validation/routing guardrails, tests, and documentation.","acceptance_criteria":"Users can configure, validate, save, clear, and reload a self-hosted Stremio Server URL; Dreamio passes it to web.stremio.com via the existing streamingServerUrl flow; no Dreamio-hosted URL is hardcoded; URL validation and logging avoid leaking tokens; docs explain server-docker capabilities and limitations.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T16:29:54Z","created_by":"dirtydishes","updated_at":"2026-05-27T17:03:32Z","started_at":"2026-05-27T16:29:59Z","closed_at":"2026-05-27T17:03:32Z","close_reason":"Implemented optional user-configured Remote Stremio Server support with URL validation, Stremio Web streamingServerUrl injection, connection testing, docs, tests, and follow-up issue dreamio-8i5 for real-device media validation.","dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-ccn","title":"Start media buffering before caption loading","description":"Ensure native media playback starts buffering immediately and subtitle resolution/attachment runs in the background so captions do not delay playback startup.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T04:07:30Z","created_by":"dirtydishes","updated_at":"2026-05-27T04:09:21Z","started_at":"2026-05-27T04:07:31Z","closed_at":"2026-05-27T04:09:21Z","close_reason":"Implemented playback-first native startup and background parallel subtitle resolution.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-e3u","title":"Harden VLC play pause synchronization","description":"Implement state-aware MobileVLCKit play/pause handling, instrumentation, readiness gating, conservative caching, and validation for pause/audio lag.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T03:43:10Z","created_by":"dirtydishes","updated_at":"2026-05-27T03:47:00Z","started_at":"2026-05-27T03:43:55Z","closed_at":"2026-05-27T03:47:00Z","close_reason":"Implemented VLC playback state hardening, instrumentation, ready-once reporting, refresh throttling, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-060","title":"Improve native player controls experience","description":"Implement Liquid Glass-inspired native player UI improvements, touch target updates, scrubbing feedback, gestures, loading and failure states, menu polish, accessibility, and validation.","acceptance_criteria":"Native player controls are modernized; touch targets and scrubbing improve; gestures, loading/failure affordances, menu labels, visual polish, device adaptation, and accessibility are implemented; build validation is run.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T01:51:52Z","created_by":"dirtydishes","updated_at":"2026-05-27T01:56:02Z","started_at":"2026-05-27T01:51:57Z","closed_at":"2026-05-27T01:56:02Z","close_reason":"Implemented native player Liquid Glass UX improvements and validated simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
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 */; };
|
||||
6F2A2B522C00100100DREAMIO /* RemoteStremioServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B532C00100100DREAMIO /* RemoteStremioServer.swift */; };
|
||||
BA013CEC876B829A86AE8DCB /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; };
|
||||
/* End PBXBuildFile 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 /* RemoteStremioServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteStremioServer.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>"; };
|
||||
|
|
@ -89,6 +91,7 @@
|
|||
6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */,
|
||||
6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */,
|
||||
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,
|
||||
6F2A2B532C00100100DREAMIO /* RemoteStremioServer.swift */,
|
||||
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,
|
||||
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,
|
||||
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,
|
||||
|
|
@ -235,6 +238,7 @@
|
|||
6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */,
|
||||
6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */,
|
||||
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */,
|
||||
6F2A2B522C00100100DREAMIO /* RemoteStremioServer.swift in Sources */,
|
||||
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */,
|
||||
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */,
|
||||
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,23 @@ final class DreamioWebViewController: UIViewController {
|
|||
private var currentNativePlaybackKey: URL?
|
||||
private weak var currentNativePlayer: NativePlayerViewController?
|
||||
private let streamResolver: StreamResolving = StremioStreamResolver()
|
||||
private let remoteServerStore = RemoteStremioServerStore()
|
||||
private let remoteServerValidator = RemoteStremioServerValidator()
|
||||
|
||||
private lazy var remoteServerButton: UIButton = {
|
||||
let button = UIButton(type: .system)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.setImage(UIImage(systemName: "server.rack"), for: .normal)
|
||||
button.tintColor = UIColor(red: 0.55, green: 0.35, blue: 0.95, alpha: 1.0)
|
||||
button.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.72)
|
||||
button.layer.cornerRadius = 22
|
||||
button.layer.borderColor = UIColor.label.withAlphaComponent(0.12).cgColor
|
||||
button.layer.borderWidth = 1
|
||||
button.accessibilityLabel = "Remote Stremio Server"
|
||||
button.accessibilityHint = "Opens advanced settings for a self-hosted Stremio Server."
|
||||
button.addTarget(self, action: #selector(showRemoteServerMenu), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
private static let streamCandidateScript = WKUserScript(
|
||||
source: #"""
|
||||
|
|
@ -723,6 +740,7 @@ final class DreamioWebViewController: UIViewController {
|
|||
view.backgroundColor = .systemBackground
|
||||
view.addSubview(webView)
|
||||
view.addSubview(progressView)
|
||||
view.addSubview(remoteServerButton)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
|
|
@ -731,7 +749,11 @@ final class DreamioWebViewController: UIViewController {
|
|||
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
progressView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
progressView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
progressView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
|
||||
progressView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
remoteServerButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
|
||||
remoteServerButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12),
|
||||
remoteServerButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
remoteServerButton.heightAnchor.constraint(equalToConstant: 44)
|
||||
])
|
||||
|
||||
progressObservation = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] webView, _ in
|
||||
|
|
@ -745,7 +767,11 @@ final class DreamioWebViewController: UIViewController {
|
|||
}
|
||||
|
||||
private func loadDreamio() {
|
||||
let request = URLRequest(url: Constants.stremioWebURL)
|
||||
let url = RemoteStremioServerConfiguration.stremioWebURL(
|
||||
baseURL: Constants.stremioWebURL,
|
||||
serverURL: remoteServerStore.serverURL
|
||||
)
|
||||
let request = URLRequest(url: url)
|
||||
webView.load(request)
|
||||
}
|
||||
|
||||
|
|
@ -766,6 +792,162 @@ final class DreamioWebViewController: UIViewController {
|
|||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc private func showRemoteServerMenu() {
|
||||
let currentURL = remoteServerStore.serverURL
|
||||
let currentDisplay = RemoteStremioServerConfiguration.redactedDisplayString(for: currentURL)
|
||||
let message = currentURL == nil
|
||||
? "Optional power-user feature. Configure your own Stremio Server; Dreamio does not provide or hardcode a server."
|
||||
: "Current server: \(currentDisplay)\nDreamio passes this to Stremio Web as streamingServerUrl and keeps native VLC playback as fallback."
|
||||
|
||||
let alert = UIAlertController(
|
||||
title: "Remote Stremio Server",
|
||||
message: message,
|
||||
preferredStyle: .actionSheet
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Configure Server URL", style: .default) { [weak self] _ in
|
||||
self?.showRemoteServerConfigurationPrompt(prefill: currentURL)
|
||||
})
|
||||
if let currentURL {
|
||||
alert.addAction(UIAlertAction(title: "Test Connection", style: .default) { [weak self] _ in
|
||||
self?.testRemoteServer(currentURL)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Reload Stremio Web", style: .default) { [weak self] _ in
|
||||
self?.loadDreamio()
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Clear Dreamio Override", style: .destructive) { [weak self] _ in
|
||||
self?.remoteServerStore.clear()
|
||||
self?.loadDreamio()
|
||||
self?.showRemoteServerNotice(
|
||||
title: "Remote server override cleared",
|
||||
message: "Dreamio will stop injecting a streamingServerUrl on load. If Stremio Web already saved this server, remove or change it in Stremio Web Settings > Streaming."
|
||||
)
|
||||
})
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
if let popover = alert.popoverPresentationController {
|
||||
popover.sourceView = remoteServerButton
|
||||
popover.sourceRect = remoteServerButton.bounds
|
||||
}
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func showRemoteServerConfigurationPrompt(prefill: URL?) {
|
||||
let alert = UIAlertController(
|
||||
title: "Configure Remote Stremio Server",
|
||||
message: "Enter the base URL for a Stremio Server you run yourself. HTTPS is recommended for remote servers; localhost and private-network HTTP are allowed for advanced setups.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addTextField { textField in
|
||||
textField.placeholder = "https://stremio.example.com:12470/"
|
||||
textField.text = prefill?.absoluteString
|
||||
textField.keyboardType = .URL
|
||||
textField.autocapitalizationType = .none
|
||||
textField.autocorrectionType = .no
|
||||
textField.clearButtonMode = .whileEditing
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "Save", style: .default) { [weak self, weak alert] _ in
|
||||
let input = alert?.textFields?.first?.text ?? ""
|
||||
self?.saveRemoteServerInput(input)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func saveRemoteServerInput(_ input: String, allowInsecureRemoteHTTP: Bool = false) {
|
||||
do {
|
||||
let configuration = try RemoteStremioServerConfiguration(
|
||||
input: input,
|
||||
allowInsecureRemoteHTTP: allowInsecureRemoteHTTP
|
||||
)
|
||||
remoteServerStore.save(serverURL: configuration.baseURL)
|
||||
loadDreamio()
|
||||
showRemoteServerSavedAlert(configuration.baseURL)
|
||||
} catch let validationError as RemoteStremioServerURLValidationError {
|
||||
if case .insecureRemoteHTTP = validationError, !allowInsecureRemoteHTTP {
|
||||
showInsecureHTTPConfirmation(input: input, message: validationError.localizedDescription)
|
||||
} else {
|
||||
showRemoteServerNotice(title: "Invalid Stremio Server URL", message: validationError.localizedDescription)
|
||||
}
|
||||
} catch {
|
||||
showRemoteServerNotice(title: "Invalid Stremio Server URL", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func showInsecureHTTPConfirmation(input: String, message: String) {
|
||||
let alert = UIAlertController(
|
||||
title: "Use insecure HTTP?",
|
||||
message: "\(message)\n\nOnly continue if this server is reachable through a trusted VPN, tunnel, or private network. The server can see stream URLs sent to it.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Edit URL", style: .cancel) { [weak self] _ in
|
||||
self?.showRemoteServerConfigurationPrompt(prefill: nil)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Use HTTP Anyway", style: .destructive) { [weak self] _ in
|
||||
self?.saveRemoteServerInput(input, allowInsecureRemoteHTTP: true)
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func showRemoteServerSavedAlert(_ serverURL: URL) {
|
||||
let displayURL = RemoteStremioServerConfiguration.redactedDisplayString(for: serverURL)
|
||||
let alert = UIAlertController(
|
||||
title: "Remote server saved",
|
||||
message: "Dreamio reloaded Stremio Web with \(displayURL). Test the connection if this is a new server.",
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Test Connection", style: .default) { [weak self] _ in
|
||||
self?.testRemoteServer(serverURL)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func testRemoteServer(_ serverURL: URL) {
|
||||
let progress = UIAlertController(
|
||||
title: "Testing Remote Stremio Server",
|
||||
message: RemoteStremioServerConfiguration.redactedDisplayString(for: serverURL),
|
||||
preferredStyle: .alert
|
||||
)
|
||||
present(progress, animated: true)
|
||||
|
||||
Task { [weak self, weak progress] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
let summary = try await remoteServerValidator.validate(serverURL: serverURL)
|
||||
await MainActor.run {
|
||||
progress?.dismiss(animated: true) {
|
||||
self.showRemoteServerTestResult(summary)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
progress?.dismiss(animated: true) {
|
||||
self.showRemoteServerNotice(
|
||||
title: "Remote server test failed",
|
||||
message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showRemoteServerTestResult(_ summary: RemoteStremioServerValidationSummary) {
|
||||
let reportedBaseURL = summary.reportedBaseURL.map {
|
||||
RemoteStremioServerConfiguration.redactedDisplayString(for: $0)
|
||||
} ?? "Not reported"
|
||||
let message = "Server version: \(summary.serverVersion)\nSettings endpoint: \(RemoteStremioServerConfiguration.redactedDisplayString(for: summary.settingsEndpoint))\nReported base URL: \(reportedBaseURL)\nTranscoding setting advertised: \(summary.hasTranscodingSetting ? "Yes" : "No")"
|
||||
showRemoteServerNotice(title: "Remote server is reachable", message: message)
|
||||
}
|
||||
|
||||
private func showRemoteServerNotice(title: String, message: String) {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func handleStreamCandidate(_ candidate: StreamCandidate) {
|
||||
guard let request = StreamClassifier.playbackRequest(from: candidate, userAgent: userAgent) else {
|
||||
return
|
||||
|
|
|
|||
239
Dreamio/RemoteStremioServer.swift
Normal file
239
Dreamio/RemoteStremioServer.swift
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import Foundation
|
||||
|
||||
enum RemoteStremioServerURLValidationError: LocalizedError, Equatable {
|
||||
case empty
|
||||
case unsupportedScheme(String)
|
||||
case missingHost
|
||||
case containsCredentials
|
||||
case containsQueryOrFragment
|
||||
case insecureRemoteHTTP(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .empty:
|
||||
return "Enter the base URL for your Stremio Server."
|
||||
case let .unsupportedScheme(scheme):
|
||||
return "Stremio Server URLs must use http or https, not \(scheme)."
|
||||
case .missingHost:
|
||||
return "The Stremio Server URL needs a host name or IP address."
|
||||
case .containsCredentials:
|
||||
return "Do not include usernames, passwords, or tokens in the Stremio Server URL."
|
||||
case .containsQueryOrFragment:
|
||||
return "Use the Stremio Server base URL only, without query strings or fragments."
|
||||
case let .insecureRemoteHTTP(host):
|
||||
return "HTTP is only accepted automatically for localhost or private-network addresses. Use HTTPS for \(host), or explicitly allow insecure HTTP."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteStremioServerConfiguration: Equatable {
|
||||
static let streamingServerQueryItem = "streamingServerUrl"
|
||||
|
||||
let baseURL: URL
|
||||
|
||||
init(input: String, allowInsecureRemoteHTTP: Bool = false) throws {
|
||||
baseURL = try Self.normalizedURL(from: input, allowInsecureRemoteHTTP: allowInsecureRemoteHTTP)
|
||||
}
|
||||
|
||||
static func normalizedURL(from input: String, allowInsecureRemoteHTTP: Bool = false) throws -> URL {
|
||||
var value = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else {
|
||||
throw RemoteStremioServerURLValidationError.empty
|
||||
}
|
||||
|
||||
if !value.contains("://") {
|
||||
value = "https://\(value)"
|
||||
}
|
||||
|
||||
guard var components = URLComponents(string: value) else {
|
||||
throw RemoteStremioServerURLValidationError.missingHost
|
||||
}
|
||||
|
||||
let scheme = components.scheme?.lowercased() ?? ""
|
||||
guard ["http", "https"].contains(scheme) else {
|
||||
throw RemoteStremioServerURLValidationError.unsupportedScheme(scheme.isEmpty ? "missing scheme" : scheme)
|
||||
}
|
||||
components.scheme = scheme
|
||||
|
||||
guard let host = components.host, !host.isEmpty else {
|
||||
throw RemoteStremioServerURLValidationError.missingHost
|
||||
}
|
||||
|
||||
guard components.user == nil, components.password == nil else {
|
||||
throw RemoteStremioServerURLValidationError.containsCredentials
|
||||
}
|
||||
|
||||
guard components.query == nil, components.fragment == nil else {
|
||||
throw RemoteStremioServerURLValidationError.containsQueryOrFragment
|
||||
}
|
||||
|
||||
if scheme == "http", !isLocalOrPrivateHost(host), !allowInsecureRemoteHTTP {
|
||||
throw RemoteStremioServerURLValidationError.insecureRemoteHTTP(host)
|
||||
}
|
||||
|
||||
if components.path.isEmpty {
|
||||
components.path = "/"
|
||||
} else if !components.path.hasSuffix("/") {
|
||||
components.path += "/"
|
||||
}
|
||||
|
||||
guard let url = components.url else {
|
||||
throw RemoteStremioServerURLValidationError.missingHost
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
static func stremioWebURL(baseURL: URL, serverURL: URL?) -> URL {
|
||||
guard let serverURL,
|
||||
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
|
||||
else {
|
||||
return baseURL
|
||||
}
|
||||
|
||||
var queryItems = components.queryItems?.filter { $0.name != streamingServerQueryItem } ?? []
|
||||
queryItems.append(URLQueryItem(name: streamingServerQueryItem, value: serverURL.absoluteString))
|
||||
components.queryItems = queryItems
|
||||
return components.url ?? baseURL
|
||||
}
|
||||
|
||||
static func settingsEndpoint(for serverURL: URL) -> URL {
|
||||
serverURL.appendingPathComponent("settings")
|
||||
}
|
||||
|
||||
static func redactedDisplayString(for serverURL: URL?) -> String {
|
||||
guard let serverURL,
|
||||
var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)
|
||||
else {
|
||||
return "Not configured"
|
||||
}
|
||||
|
||||
components.user = nil
|
||||
components.password = nil
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
return components.url?.absoluteString ?? "Configured server"
|
||||
}
|
||||
|
||||
static func isLocalOrPrivateHost(_ host: String) -> Bool {
|
||||
let lowercased = host.lowercased()
|
||||
if lowercased == "localhost" || lowercased == "::1" || lowercased == "[::1]" {
|
||||
return true
|
||||
}
|
||||
|
||||
if lowercased.hasPrefix("fe80:") || lowercased.hasPrefix("fc") || lowercased.hasPrefix("fd") {
|
||||
return true
|
||||
}
|
||||
|
||||
let octets = lowercased.split(separator: ".").compactMap { Int($0) }
|
||||
guard octets.count == 4 else {
|
||||
return false
|
||||
}
|
||||
|
||||
if octets[0] == 10 || octets[0] == 127 {
|
||||
return true
|
||||
}
|
||||
if octets[0] == 192, octets[1] == 168 {
|
||||
return true
|
||||
}
|
||||
if octets[0] == 172, (16...31).contains(octets[1]) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteStremioServerStore {
|
||||
static let storageKey = "Dreamio.RemoteStremioServer.baseURL"
|
||||
|
||||
private let userDefaults: UserDefaults
|
||||
|
||||
init(userDefaults: UserDefaults = .standard) {
|
||||
self.userDefaults = userDefaults
|
||||
}
|
||||
|
||||
var serverURL: URL? {
|
||||
guard let value = userDefaults.string(forKey: Self.storageKey),
|
||||
let url = try? RemoteStremioServerConfiguration.normalizedURL(
|
||||
from: value,
|
||||
allowInsecureRemoteHTTP: true
|
||||
)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
func save(serverURL: URL) {
|
||||
userDefaults.set(serverURL.absoluteString, forKey: Self.storageKey)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
userDefaults.removeObject(forKey: Self.storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteStremioServerValidationSummary: Equatable {
|
||||
let serverURL: URL
|
||||
let settingsEndpoint: URL
|
||||
let serverVersion: String
|
||||
let reportedBaseURL: URL?
|
||||
let hasTranscodingSetting: Bool
|
||||
}
|
||||
|
||||
enum RemoteStremioServerValidationError: LocalizedError {
|
||||
case invalidResponse
|
||||
case httpStatus(Int)
|
||||
case missingServerVersion
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse:
|
||||
return "The server did not return a valid Stremio Server settings response."
|
||||
case let .httpStatus(status):
|
||||
return "The Stremio Server settings endpoint returned HTTP \(status)."
|
||||
case .missingServerVersion:
|
||||
return "The settings response did not include a Stremio Server version."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class RemoteStremioServerValidator {
|
||||
private let session: URLSession
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func validate(serverURL: URL) async throws -> RemoteStremioServerValidationSummary {
|
||||
let endpoint = RemoteStremioServerConfiguration.settingsEndpoint(for: serverURL)
|
||||
var request = URLRequest(url: endpoint)
|
||||
request.timeoutInterval = 10
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw RemoteStremioServerValidationError.invalidResponse
|
||||
}
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
throw RemoteStremioServerValidationError.httpStatus(httpResponse.statusCode)
|
||||
}
|
||||
guard let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let values = payload["values"] as? [String: Any]
|
||||
else {
|
||||
throw RemoteStremioServerValidationError.invalidResponse
|
||||
}
|
||||
guard let serverVersion = values["serverVersion"] as? String, !serverVersion.isEmpty else {
|
||||
throw RemoteStremioServerValidationError.missingServerVersion
|
||||
}
|
||||
|
||||
let reportedBaseURL = (payload["baseUrl"] as? String).flatMap(URL.init(string:))
|
||||
return RemoteStremioServerValidationSummary(
|
||||
serverURL: serverURL,
|
||||
settingsEndpoint: endpoint,
|
||||
serverVersion: serverVersion,
|
||||
reportedBaseURL: reportedBaseURL,
|
||||
hasTranscodingSetting: values.keys.contains("transcodeProfile")
|
||||
)
|
||||
}
|
||||
}
|
||||
41
README.md
41
README.md
|
|
@ -52,6 +52,47 @@ open Dreamio.xcworkspace
|
|||
The official CocoaPods getting started guide documents the RubyGems install
|
||||
path: https://guides.cocoapods.org/using/getting-started.html
|
||||
|
||||
## Optional Remote Stremio Server
|
||||
|
||||
Dreamio includes an advanced, opt-in **Remote Stremio Server** setting for users
|
||||
who run their own Stremio Server, such as the official
|
||||
[`stremio/server`](https://github.com/Stremio/server-docker) Docker image. Tap
|
||||
the server button in the top-right corner of Dreamio to configure, test, reload,
|
||||
or clear a user-provided server URL.
|
||||
|
||||
Dreamio does **not** provide, bundle, or hardcode a Stremio Server address. The
|
||||
configured URL is stored locally and passed to hosted Stremio Web with its
|
||||
existing `streamingServerUrl` query-parameter flow so Stremio Web can add and
|
||||
select that server in Settings > Streaming.
|
||||
|
||||
What the official Docker image exposes:
|
||||
|
||||
- `11470` for HTTP and `12470` for HTTPS.
|
||||
- `/settings`, `/network-info`, `/device-info`, `/casting`, torrent creation,
|
||||
statistics, proxy, and media streaming endpoints used by Stremio Web/Core.
|
||||
- `FFMPEG_BIN`, `FFPROBE_BIN`, `APP_PATH`, and `NO_CORS` environment variables.
|
||||
- A Docker build with `ffmpeg-jellyfin`, which enables server-side media work
|
||||
when the upstream Stremio Server and selected stream path support it.
|
||||
|
||||
A remote server can help with torrent-backed streams, proxy-header streams,
|
||||
archive/FTP/YouTube handling, and some transcoding-capable playback paths. It is
|
||||
not guaranteed to turn every MKV into a WebKit-friendly HLS or MP4 stream. Keep
|
||||
MobileVLCKit native playback available as the fallback for MKV, AVI, WebM, codec,
|
||||
audio-track, subtitle, or server-transcoding failures.
|
||||
|
||||
Security and privacy notes:
|
||||
|
||||
- Prefer HTTPS for remote servers. Dreamio only accepts HTTP automatically for
|
||||
localhost and private-network addresses; public HTTP requires explicit
|
||||
confirmation. Use a trusted certificate or reverse proxy for remote HTTPS;
|
||||
self-signed certificates may fail in iOS networking and `WKWebView`.
|
||||
- Do not put usernames, passwords, tokens, query strings, or fragments in the
|
||||
configured base URL.
|
||||
- A configured server can see stream URLs sent to it. Run a server you trust.
|
||||
- Clearing the Dreamio override stops Dreamio from injecting `streamingServerUrl`
|
||||
on load. If Stremio Web already saved the URL in its own profile settings,
|
||||
remove or change it in Stremio Web Settings > Streaming.
|
||||
|
||||
## Validation Notes
|
||||
|
||||
CocoaPods 1.16.2 was installed with Homebrew on this repository machine, and
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ struct StreamResolverTests {
|
|||
testNativePlaybackTogglePolicy()
|
||||
testNativePlaybackAudioSessionPolicy()
|
||||
testNativePlaybackStreamingOptionsPolicy()
|
||||
testRemoteStremioServerURLNormalization()
|
||||
testRemoteStremioServerWebURLInjection()
|
||||
await testRemoteStremioServerValidatorReadsSettingsEndpoint()
|
||||
print("StreamResolverTests passed")
|
||||
}
|
||||
|
||||
|
|
@ -536,6 +539,89 @@ struct StreamResolverTests {
|
|||
assertEqual(NativePlaybackStreamingOptionsPolicy.mediaOptions().contains(":live-caching=1000"), false)
|
||||
}
|
||||
|
||||
private static func testRemoteStremioServerURLNormalization() {
|
||||
let serverURL = try! RemoteStremioServerConfiguration.normalizedURL(from: "stremio.example.test:12470")
|
||||
assertEqual(serverURL.absoluteString, "https://stremio.example.test:12470/")
|
||||
|
||||
let reverseProxyURL = try! RemoteStremioServerConfiguration.normalizedURL(from: "https://media.example.test/stremio")
|
||||
assertEqual(reverseProxyURL.absoluteString, "https://media.example.test/stremio/")
|
||||
|
||||
let localHTTPURL = try! RemoteStremioServerConfiguration.normalizedURL(from: "http://192.168.1.10:11470")
|
||||
assertEqual(localHTTPURL.absoluteString, "http://192.168.1.10:11470/")
|
||||
|
||||
do {
|
||||
_ = try RemoteStremioServerConfiguration.normalizedURL(from: "http://public.example.test:11470")
|
||||
assertionFailure("Expected remote HTTP to require an explicit override")
|
||||
} catch RemoteStremioServerURLValidationError.insecureRemoteHTTP(let host) {
|
||||
assertEqual(host, "public.example.test")
|
||||
} catch {
|
||||
assertionFailure("Unexpected error: \(error)")
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try RemoteStremioServerConfiguration.normalizedURL(from: "https://user:secret@stremio.example.test:12470/")
|
||||
assertionFailure("Expected credentials to be rejected")
|
||||
} catch RemoteStremioServerURLValidationError.containsCredentials {
|
||||
} catch {
|
||||
assertionFailure("Unexpected error: \(error)")
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try RemoteStremioServerConfiguration.normalizedURL(from: "https://stremio.example.test:12470/?token=secret")
|
||||
assertionFailure("Expected query strings to be rejected")
|
||||
} catch RemoteStremioServerURLValidationError.containsQueryOrFragment {
|
||||
} catch {
|
||||
assertionFailure("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func testRemoteStremioServerWebURLInjection() {
|
||||
let webURL = URL(string: "https://web.stremio.com/")!
|
||||
let serverURL = URL(string: "https://stremio.example.test:12470/")!
|
||||
let configuredURL = RemoteStremioServerConfiguration.stremioWebURL(
|
||||
baseURL: webURL,
|
||||
serverURL: serverURL
|
||||
)
|
||||
let queryItems = URLComponents(url: configuredURL, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
assertEqual(
|
||||
queryItems.first(where: { $0.name == RemoteStremioServerConfiguration.streamingServerQueryItem })?.value,
|
||||
"https://stremio.example.test:12470/"
|
||||
)
|
||||
assertEqual(
|
||||
RemoteStremioServerConfiguration.stremioWebURL(baseURL: webURL, serverURL: nil).absoluteString,
|
||||
"https://web.stremio.com/"
|
||||
)
|
||||
}
|
||||
|
||||
private static func testRemoteStremioServerValidatorReadsSettingsEndpoint() async {
|
||||
let session = mockSession()
|
||||
let serverURL = URL(string: "https://stremio.example.test:12470/")!
|
||||
let endpoint = URL(string: "https://stremio.example.test:12470/settings")!
|
||||
let body = #"""
|
||||
{
|
||||
"baseUrl": "https://stremio.example.test:12470/",
|
||||
"values": {
|
||||
"serverVersion": "4.20.8",
|
||||
"appPath": "/root/.stremio-server",
|
||||
"cacheRoot": "/root/.stremio-server",
|
||||
"cacheSize": 2147483648,
|
||||
"remoteHttps": "",
|
||||
"transcodeProfile": null
|
||||
}
|
||||
}
|
||||
"""#
|
||||
MockURLProtocol.handlers = [
|
||||
endpoint.absoluteString: (status: 200, url: endpoint, data: Data(body.utf8))
|
||||
]
|
||||
|
||||
let summary = try! await RemoteStremioServerValidator(session: session).validate(serverURL: serverURL)
|
||||
|
||||
assertEqual(summary.serverVersion, "4.20.8")
|
||||
assertEqual(summary.settingsEndpoint.absoluteString, "https://stremio.example.test:12470/settings")
|
||||
assertEqual(summary.reportedBaseURL?.absoluteString, "https://stremio.example.test:12470/")
|
||||
assertEqual(summary.hasTranscodingSetting, true)
|
||||
}
|
||||
|
||||
private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
|
||||
assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
|
||||
}
|
||||
|
|
|
|||
690
docs/turns/2026-05-27-remote-stremio-server.html
Normal file
690
docs/turns/2026-05-27-remote-stremio-server.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