add remote stremio server support

This commit is contained in:
dirtydishes 2026-05-27 13:04:11 -04:00
parent e7ddd6d755
commit 592dc12970
8 changed files with 1247 additions and 2 deletions

View file

@ -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."}}

View file

@ -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}

View file

@ -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 */,

View file

@ -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

View 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")
)
}
}

View file

@ -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

View file

@ -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)
}

File diff suppressed because one or more lines are too long