Dreamio turn document

Remote Stremio Server

Dreamio now has an advanced, user-owned Stremio Server configuration path that passes a self-hosted server into Stremio Web without hardcoded infrastructure.

2026-05-27 Beads issue dreamio-dc6 Follow-up dreamio-8i5

Summary

Implemented the optional Remote Stremio Server feature as a power-user control. Users can save, validate, reload, or clear their own server URL. Dreamio injects it through Stremio Web's existing streamingServerUrl query flow and keeps native VLC playback available as the reliable fallback.

Changes Made

  • Added RemoteStremioServer.swift for URL normalization, private-network HTTP policy, local storage, safe display redaction, and /settings validation.
  • Added a compact advanced server button to DreamioWebViewController with configure, test, reload, and clear actions.
  • Changed Dreamio launch URLs so a configured server is passed to hosted Stremio Web as streamingServerUrl.
  • Added standalone Swift coverage for URL policy, Web URL injection, and settings-endpoint validation.
  • Documented what Stremio/server-docker exposes, what it can help with, and where transcoding expectations must remain cautious.

Context

Upstream research showed that Stremio/server-docker exposes HTTP on 11470, HTTPS on 12470, and server endpoints such as /settings, /network-info, /device-info, /casting, torrent creation, statistics, proxy, and stream URLs. Stremio Web already supports a streamingServerUrl query parameter that dispatches AddServerUrl and updates profile settings. That existing integration is less brittle than rewriting stream URLs inside Dreamio.

Important Implementation Details

  • No Dreamio-owned server URL is hardcoded. The only persistent value is the user's local setting.
  • URLs without a scheme default to HTTPS. HTTP is allowed automatically for localhost and private-network addresses, while public HTTP requires explicit confirmation.
  • Credentials, query strings, and fragments are rejected so tokens are not stored as part of the server base URL.
  • The connection test reads /settings, requires values.serverVersion, reports the upstream baseUrl, and notes whether transcodeProfile is advertised.
  • Clearing Dreamio's override stops future injection. The UI explains that Stremio Web may still retain the URL in its own Settings > Streaming profile state.

Relevant Diff Snippets

Dreamio/RemoteStremioServer.swift ยท validation, storage, and server health checks

Dreamio/RemoteStremioServer.swift
+239
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
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")
)
}
}

Dreamio/DreamioWebViewController.swift ยท advanced user controls and Stremio Web injection

Dreamio/DreamioWebViewController.swift
-2+184
60 unmodified lines
61
62
63
64
65
66
656 unmodified lines
723
724
725
726
727
728
2 unmodified lines
731
732
733
734
735
736
737
7 unmodified lines
745
746
747
748
749
750
751
14 unmodified lines
766
767
768
769
770
771
60 unmodified lines
private var currentNativePlaybackKey: URL?
private weak var currentNativePlayer: NativePlayerViewController?
private let streamResolver: StreamResolving = StremioStreamResolver()
private static let streamCandidateScript = WKUserScript(
source: #"""
656 unmodified lines
view.backgroundColor = .systemBackground
view.addSubview(webView)
view.addSubview(progressView)
NSLayoutConstraint.activate([
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
2 unmodified lines
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)
])
progressObservation = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] webView, _ in
7 unmodified lines
}
private func loadDreamio() {
let request = URLRequest(url: Constants.stremioWebURL)
webView.load(request)
}
14 unmodified lines
present(alert, animated: true)
}
private func handleStreamCandidate(_ candidate: StreamCandidate) {
guard let request = StreamClassifier.playbackRequest(from: candidate, userAgent: userAgent) else {
return
60 unmodified lines
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
656 unmodified lines
740
741
742
743
744
745
746
2 unmodified lines
749
750
751
752
753
754
755
756
757
758
759
7 unmodified lines
767
768
769
770
771
772
773
774
775
776
777
14 unmodified lines
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
60 unmodified lines
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: #"""
656 unmodified lines
view.backgroundColor = .systemBackground
view.addSubview(webView)
view.addSubview(progressView)
view.addSubview(remoteServerButton)
NSLayoutConstraint.activate([
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
2 unmodified lines
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),
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
7 unmodified lines
}
private func loadDreamio() {
let url = RemoteStremioServerConfiguration.stremioWebURL(
baseURL: Constants.stremioWebURL,
serverURL: remoteServerStore.serverURL
)
let request = URLRequest(url: url)
webView.load(request)
}
14 unmodified lines
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

Tests/StreamResolverTests.swift ยท routing and validation coverage

Tests/StreamResolverTests.swift
+86
26 unmodified lines
27
28
29
30
31
32
503 unmodified lines
536
537
538
539
540
541
26 unmodified lines
testNativePlaybackTogglePolicy()
testNativePlaybackAudioSessionPolicy()
testNativePlaybackStreamingOptionsPolicy()
print("StreamResolverTests passed")
}
503 unmodified lines
assertEqual(NativePlaybackStreamingOptionsPolicy.mediaOptions().contains(":live-caching=1000"), false)
}
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)
}
26 unmodified lines
27
28
29
30
31
32
33
34
35
503 unmodified lines
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
26 unmodified lines
testNativePlaybackTogglePolicy()
testNativePlaybackAudioSessionPolicy()
testNativePlaybackStreamingOptionsPolicy()
testRemoteStremioServerURLNormalization()
testRemoteStremioServerWebURLInjection()
await testRemoteStremioServerValidatorReadsSettingsEndpoint()
print("StreamResolverTests passed")
}
503 unmodified lines
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)
}

README.md ยท power-user setup and limitations

README.md
+41
51 unmodified lines
52
53
54
55
56
57
51 unmodified lines
The official CocoaPods getting started guide documents the RubyGems install
path: https://guides.cocoapods.org/using/getting-started.html
## Validation Notes
CocoaPods 1.16.2 was installed with Homebrew on this repository machine, and
51 unmodified lines
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
51 unmodified lines
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

Diffs were rendered with @pierre/diffs/ssr. Each file diff is contained in its own shell so the document remains a readable static artifact.

Expected Impact for End-Users

Power users can now point Dreamio at their own Stremio Server, including a Docker deployment behind their own HTTPS endpoint. The feature may help with torrent-backed, proxy-header, archive, FTP, YouTube, and some transcoding-capable paths, but users still get Dreamio's MobileVLCKit fallback for MKV, AVI, WebM, codec, audio, subtitle, or server limitations.

Validation

  • Passed: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun --sdk iphonesimulator swiftc -target arm64-apple-ios18.0-simulator Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/RemoteStremioServer.swift Dreamio/NativePlaybackBackend.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests-ios.
  • Passed through simulator spawn: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun simctl spawn 881FA280-CAA8-49B5-85BB-E7D393CCA6D2 /tmp/dreamio-stream-tests-ios.
  • Passed: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build.
  • Not run: real-device media playback against a user-owned Stremio Server. That requires a server, TLS setup, and legal representative streams, and is tracked by dreamio-8i5.

Issues, Limitations, and Mitigations

  • Server-side MKV to WebKit-friendly output is not guaranteed. The README now states this explicitly and Dreamio keeps native VLC fallback behavior.
  • Self-signed or untrusted certificates may fail in iOS networking and WKWebView. The documentation recommends a trusted certificate or reverse proxy.
  • The Stremio Web profile can save the server independently of Dreamio. The clear action explains how to remove that saved value inside Stremio Web.
  • Public HTTP can expose stream URLs. Dreamio blocks it by default and requires an explicit warning confirmation.

Follow-up Work

  • Complete dreamio-8i5: validate direct MP4, HLS, MKV, unsupported audio/subtitle MKV, and torrent-backed samples on real iPhone or iPad with a user-owned server.
  • Consider a richer first-run explanation if the compact server button is too discoverable for a power-user feature or too subtle for testers.
  • After device validation, adjust routing copy or fallback behavior if Stremio Server reliably emits WebKit-friendly HLS for specific stream classes.