add native debrid stream playback

This commit is contained in:
dirtydishes 2026-05-24 23:20:32 -04:00
parent 3df2e2b833
commit d28540ce98
12 changed files with 936 additions and 62 deletions

View file

@ -5,6 +5,7 @@ final class DreamioWebViewController: UIViewController {
private enum Constants {
static let stremioWebURL = URL(string: "https://web.stremio.com/")!
static let diagnosticsMessageHandler = "dreamioDiagnostics"
static let streamCandidateMessageHandler = "dreamioStreamCandidate"
}
private lazy var webView: WKWebView = {
@ -13,6 +14,11 @@ final class DreamioWebViewController: UIViewController {
configuration.allowsInlineMediaPlayback = true
configuration.mediaTypesRequiringUserActionForPlayback = []
configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
configuration.userContentController.add(
WeakScriptMessageHandler(delegate: self),
name: Constants.streamCandidateMessageHandler
)
configuration.userContentController.addUserScript(Self.streamCandidateScript)
#if DEBUG
configuration.userContentController.add(
WeakScriptMessageHandler(delegate: self),
@ -44,6 +50,142 @@ final class DreamioWebViewController: UIViewController {
}()
private var progressObservation: NSKeyValueObservation?
private var userAgent: String?
private var lastNativePlaybackURL: URL?
private static let streamCandidateScript = WKUserScript(
source: """
(() => {
if (window.__dreamioStreamBridgeInstalled) {
return;
}
window.__dreamioStreamBridgeInstalled = true;
const nativePatterns = [
/\/\/addon\.debridio\.com\/play\//i,
/\/\/torrentio\.strem\.fun\/resolve\//i,
/\/\/download\.real-debrid\.com\//i,
/\.(mkv|avi|webm)(?:[?#]|$)/i
];
const compatiblePatterns = [
/\.m3u8(?:[?#]|$)/i,
/\.mp4(?:[?#]|$)/i
];
const looksNative = (url) => {
if (!url || typeof url !== "string") {
return false;
}
const directMatch = nativePatterns.some((pattern) => pattern.test(url));
const compatibleMatch = compatiblePatterns.some((pattern) => pattern.test(url));
return directMatch || (!compatibleMatch && /\.(mkv|avi|webm)(?:[?#]|$)/i.test(url));
};
const absoluteURL = (url) => {
try {
return new URL(url, window.location.href).href;
} catch (_) {
return "";
}
};
const findResolverURL = () => {
const links = Array.from(document.querySelectorAll("a[href], [data-href], [data-url]"));
const match = links
.map((node) => node.getAttribute("href") || node.getAttribute("data-href") || node.getAttribute("data-url"))
.map(absoluteURL)
.find((url) => nativePatterns.some((pattern) => pattern.test(url)));
return match || "";
};
const postCandidate = (rawURL, element) => {
const url = absoluteURL(rawURL);
if (!looksNative(url)) {
return;
}
try {
window.webkit.messageHandlers.dreamioStreamCandidate.postMessage({
url,
resolverUrl: findResolverURL(),
pageUrl: window.location.href,
tagName: element && element.tagName ? element.tagName : "",
currentSrc: element && element.currentSrc ? element.currentSrc : ""
});
} catch (_) {}
};
const inspectMedia = (node) => {
if (!node) {
return;
}
if (node instanceof HTMLVideoElement || node instanceof HTMLSourceElement) {
postCandidate(node.currentSrc || node.src || node.getAttribute("src"), node);
}
if (node.querySelectorAll) {
node.querySelectorAll("video, source").forEach(inspectMedia);
}
};
const srcDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, "src");
if (srcDescriptor && srcDescriptor.set) {
Object.defineProperty(HTMLMediaElement.prototype, "src", {
get: srcDescriptor.get,
set(value) {
postCandidate(value, this);
return srcDescriptor.set.call(this, value);
}
});
}
const sourceSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLSourceElement.prototype, "src");
if (sourceSrcDescriptor && sourceSrcDescriptor.set) {
Object.defineProperty(HTMLSourceElement.prototype, "src", {
get: sourceSrcDescriptor.get,
set(value) {
postCandidate(value, this);
return sourceSrcDescriptor.set.call(this, value);
}
});
}
const originalSetAttribute = Element.prototype.setAttribute;
Element.prototype.setAttribute = function(name, value) {
if (String(name).toLowerCase() === "src" && (this instanceof HTMLVideoElement || this instanceof HTMLSourceElement)) {
postCandidate(value, this);
}
return originalSetAttribute.call(this, name, value);
};
const originalLoad = HTMLMediaElement.prototype.load;
HTMLMediaElement.prototype.load = function() {
inspectMedia(this);
this.querySelectorAll("source").forEach(inspectMedia);
return originalLoad.call(this);
};
document.addEventListener("loadedmetadata", (event) => inspectMedia(event.target), true);
document.addEventListener("error", (event) => inspectMedia(event.target), true);
new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "attributes" && mutation.attributeName === "src") {
inspectMedia(mutation.target);
}
mutation.addedNodes.forEach(inspectMedia);
});
}).observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["src"]
});
inspectMedia(document);
})();
""",
injectionTime: .atDocumentStart,
forMainFrameOnly: false
)
#if DEBUG
private static let playbackDiagnosticsScript = WKUserScript(
@ -157,6 +299,9 @@ final class DreamioWebViewController: UIViewController {
}
loadDreamio()
webView.evaluateJavaScript("navigator.userAgent") { [weak self] result, _ in
self?.userAgent = result as? String
}
}
private func loadDreamio() {
@ -181,6 +326,28 @@ final class DreamioWebViewController: UIViewController {
present(alert, animated: true)
}
private func handleStreamCandidate(_ candidate: StreamCandidate) {
guard let request = StreamClassifier.playbackRequest(from: candidate, userAgent: userAgent) else {
return
}
if lastNativePlaybackURL == request.playbackURL {
return
}
lastNativePlaybackURL = request.playbackURL
#if DEBUG
let classification = request.classification
print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")
#endif
let player = NativePlayerViewController(request: request)
player.onDismiss = { [weak self] in
self?.lastNativePlaybackURL = nil
}
present(player, animated: true)
}
#if DEBUG
private func logDiagnostic(type: String, payload: Any, pageURL: String?) {
let redactedPageURL = pageURL.map(redactedURLString) ?? "unknown"
@ -204,44 +371,7 @@ final class DreamioWebViewController: UIViewController {
}
private func redactedURLString(_ value: String) -> String {
guard var components = URLComponents(string: value), components.scheme != nil else {
return redactTokenLikeFragments(in: value)
}
components.query = nil
components.fragment = nil
if let path = components.percentEncodedPath.nilIfEmpty {
components.percentEncodedPath = redactTokenLikePathSegments(in: path)
}
return redactTokenLikeFragments(in: components.string ?? value)
}
private func redactTokenLikePathSegments(in path: String) -> String {
path
.split(separator: "/", omittingEmptySubsequences: false)
.map { segment -> String in
let text = String(segment)
if text.range(of: #"^[A-Za-z0-9_-]{24,}$"#, options: .regularExpression) != nil {
return "[redacted]"
}
return text
}
.joined(separator: "/")
}
private func redactTokenLikeFragments(in value: String) -> String {
let patterns = [
#"(?i)((?:token|access_token|auth|signature|sig|key|apikey|api_key|jwt|session|password)=)([^&\s]+)"#,
#"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+"#
]
return patterns.reduce(value) { redacted, pattern in
redacted.replacingOccurrences(
of: pattern,
with: "$1[redacted]",
options: .regularExpression
)
}
URLRedactor.redactedURLString(value)
}
#endif
}
@ -311,9 +441,15 @@ extension DreamioWebViewController: WKNavigationDelegate {
}
}
#if DEBUG
extension DreamioWebViewController: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
if message.name == Constants.streamCandidateMessageHandler,
let candidate = StreamCandidate(messageBody: message.body) {
handleStreamCandidate(candidate)
return
}
#if DEBUG
guard message.name == Constants.diagnosticsMessageHandler,
let body = message.body as? [String: Any],
let type = body["type"] as? String
@ -326,6 +462,7 @@ extension DreamioWebViewController: WKScriptMessageHandler {
payload: body["payload"] ?? [:],
pageURL: body["href"] as? String
)
#endif
}
}
@ -342,12 +479,6 @@ private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
}
}
private extension String {
var nilIfEmpty: String? {
isEmpty ? nil : self
}
}
#endif
extension DreamioWebViewController: WKUIDelegate {
func webView(

View file

@ -0,0 +1,22 @@
import UIKit
protocol NativePlaybackBackend: AnyObject {
var view: UIView { get }
var onReady: (() -> Void)? { get set }
var onFailure: ((Error) -> Void)? { get set }
func prepare(in viewController: UIViewController)
func play(request: NativePlaybackRequest)
func stop()
}
enum NativePlaybackError: LocalizedError {
case backendUnavailable
var errorDescription: String? {
switch self {
case .backendUnavailable:
return "Native playback is not available in this build."
}
}
}

View file

@ -0,0 +1,134 @@
import UIKit
final class NativePlayerViewController: UIViewController {
private let request: NativePlaybackRequest
private var backend: NativePlaybackBackend
var onDismiss: (() -> Void)?
private let loadingView: UIActivityIndicatorView = {
let view = UIActivityIndicatorView(style: .large)
view.translatesAutoresizingMaskIntoConstraints = false
view.color = .white
view.startAnimating()
return view
}()
private let closeButton: UIButton = {
let button = UIButton(type: .system)
button.translatesAutoresizingMaskIntoConstraints = false
button.setImage(UIImage(systemName: "xmark"), for: .normal)
button.tintColor = .white
button.backgroundColor = UIColor.black.withAlphaComponent(0.45)
button.layer.cornerRadius = 22
button.accessibilityLabel = "Close"
return button
}()
private let failureLabel: UILabel = {
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .white
label.textAlignment = .center
label.numberOfLines = 0
label.font = .preferredFont(forTextStyle: .body)
label.isHidden = true
return label
}()
init(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) {
self.request = request
self.backend = backend
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
modalTransitionStyle = .crossDissolve
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
.allButUpsideDown
}
override var prefersHomeIndicatorAutoHidden: Bool {
true
}
override var prefersStatusBarHidden: Bool {
true
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
configureBackend()
configureLayout()
backend.play(request: request)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
backend.stop()
onDismiss?()
}
private func configureBackend() {
backend.prepare(in: self)
backend.view.translatesAutoresizingMaskIntoConstraints = false
backend.onReady = { [weak self] in
DispatchQueue.main.async {
self?.loadingView.stopAnimating()
self?.loadingView.isHidden = true
}
}
backend.onFailure = { [weak self] error in
DispatchQueue.main.async {
self?.showFailure(error)
}
}
}
private func configureLayout() {
view.addSubview(backend.view)
view.addSubview(loadingView)
view.addSubview(failureLabel)
view.addSubview(closeButton)
closeButton.addTarget(self, action: #selector(close), for: .touchUpInside)
NSLayoutConstraint.activate([
backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
backend.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
backend.view.topAnchor.constraint(equalTo: view.topAnchor),
backend.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
failureLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 28),
failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28),
failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
closeButton.widthAnchor.constraint(equalToConstant: 44),
closeButton.heightAnchor.constraint(equalToConstant: 44),
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12)
])
}
private func showFailure(_ error: Error) {
loadingView.stopAnimating()
loadingView.isHidden = true
failureLabel.text = "Native playback could not start.\n\(error.localizedDescription)"
failureLabel.isHidden = false
#if DEBUG
print("[DreamioNativePlayer] error=\(URLRedactor.redactedURLString(error.localizedDescription))")
#endif
}
@objc private func close() {
dismiss(animated: true)
}
}

View file

@ -0,0 +1,208 @@
import Foundation
enum StreamSourceKind: String {
case debridio
case torrentio
case realDebrid
case directFile
case unknown
}
enum StreamContainerGuess: String {
case hls
case mp4
case mkv
case avi
case webm
case unknown
}
struct NativePlaybackRequest {
let playbackURL: URL
let observedURL: URL
let resolverURL: URL?
let pageURL: URL?
let userAgent: String?
let referer: String
let classification: StreamClassification
}
struct StreamClassification {
let sourceKind: StreamSourceKind
let containerGuess: StreamContainerGuess
let reason: String
let shouldIntercept: Bool
let sanitizedObservedURL: String
let sanitizedResolverURL: String?
}
struct StreamCandidate {
let observedURL: URL
let resolverURL: URL?
let pageURL: URL?
init?(messageBody: Any) {
guard let body = messageBody as? [String: Any],
let observed = Self.url(from: body["url"])
else {
return nil
}
observedURL = observed
resolverURL = Self.url(from: body["resolverUrl"])
pageURL = Self.url(from: body["pageUrl"])
}
private static func url(from value: Any?) -> URL? {
guard let string = value as? String, !string.isEmpty else {
return nil
}
return URL(string: string)
}
}
enum StreamClassifier {
static let referer = "https://web.stremio.com/"
static func playbackRequest(
from candidate: StreamCandidate,
userAgent: String?
) -> NativePlaybackRequest? {
let classification = classify(candidate: candidate)
guard classification.shouldIntercept else {
return nil
}
return NativePlaybackRequest(
playbackURL: candidate.resolverURL ?? candidate.observedURL,
observedURL: candidate.observedURL,
resolverURL: candidate.resolverURL,
pageURL: candidate.pageURL,
userAgent: userAgent,
referer: referer,
classification: classification
)
}
static func classify(candidate: StreamCandidate) -> StreamClassification {
let observed = candidate.observedURL
let resolver = candidate.resolverURL
let matchingURL = resolver ?? observed
let sourceKind = sourceKind(for: matchingURL, observedURL: observed)
let container = containerGuess(for: observed, resolverURL: resolver)
let knownDirectFile = sourceKind == .debridio || sourceKind == .torrentio || sourceKind == .realDebrid
let unsupportedContainer = [.mkv, .avi, .webm].contains(container)
let webCompatibleContainer = container == .hls || container == .mp4
let shouldIntercept = knownDirectFile || unsupportedContainer
let reason: String
if knownDirectFile {
reason = "known-direct-file-source"
} else if unsupportedContainer {
reason = "unsupported-container"
} else if webCompatibleContainer {
reason = "web-compatible-container"
} else {
reason = "no-native-rule"
}
return StreamClassification(
sourceKind: sourceKind,
containerGuess: container,
reason: reason,
shouldIntercept: shouldIntercept,
sanitizedObservedURL: URLRedactor.redactedURLString(observed.absoluteString),
sanitizedResolverURL: resolver.map { URLRedactor.redactedURLString($0.absoluteString) }
)
}
private static func sourceKind(for url: URL, observedURL: URL) -> StreamSourceKind {
let values = [url, observedURL]
if values.contains(where: { matches($0, host: "addon.debridio.com", pathPrefix: "/play/") }) {
return .debridio
}
if values.contains(where: { matches($0, host: "torrentio.strem.fun", pathPrefix: "/resolve/") }) {
return .torrentio
}
if values.contains(where: { ($0.host ?? "").lowercased() == "download.real-debrid.com" }) {
return .realDebrid
}
if [.mkv, .avi, .webm].contains(containerGuess(for: observedURL, resolverURL: url)) {
return .directFile
}
return .unknown
}
private static func matches(_ url: URL, host: String, pathPrefix: String) -> Bool {
(url.host ?? "").lowercased() == host && url.path.lowercased().hasPrefix(pathPrefix)
}
private static func containerGuess(for observedURL: URL, resolverURL: URL?) -> StreamContainerGuess {
let values = [observedURL, resolverURL].compactMap { $0 }
if values.contains(where: { $0.pathExtension.lowercased() == "m3u8" || $0.absoluteString.lowercased().contains(".m3u8") }) {
return .hls
}
for url in values {
let text = url.absoluteString.lowercased()
if url.pathExtension.lowercased() == "mp4" || text.contains(".mp4") {
return .mp4
}
if url.pathExtension.lowercased() == "mkv" || text.contains(".mkv") {
return .mkv
}
if url.pathExtension.lowercased() == "avi" || text.contains(".avi") {
return .avi
}
if url.pathExtension.lowercased() == "webm" || text.contains(".webm") {
return .webm
}
}
return .unknown
}
}
enum URLRedactor {
static func redactedURLString(_ value: String) -> String {
guard var components = URLComponents(string: value), components.scheme != nil else {
return redactTokenLikeFragments(in: value)
}
components.query = nil
components.fragment = nil
if !components.percentEncodedPath.isEmpty {
components.percentEncodedPath = redactTokenLikePathSegments(in: components.percentEncodedPath)
}
return redactTokenLikeFragments(in: components.string ?? value)
}
private static func redactTokenLikePathSegments(in path: String) -> String {
path
.split(separator: "/", omittingEmptySubsequences: false)
.map { segment -> String in
let text = String(segment)
if text.range(of: #"^[A-Za-z0-9_-]{24,}$"#, options: .regularExpression) != nil {
return "[redacted]"
}
return text
}
.joined(separator: "/")
}
private static func redactTokenLikeFragments(in value: String) -> String {
let patterns = [
#"(?i)((?:token|access_token|auth|signature|sig|key|apikey|api_key|jwt|session|password)=)([^&\s]+)"#,
#"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+"#
]
return patterns.reduce(value) { redacted, pattern in
redacted.replacingOccurrences(
of: pattern,
with: "$1[redacted]",
options: .regularExpression
)
}
}
}

View file

@ -0,0 +1,58 @@
import UIKit
import MobileVLCKit
final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
let view = UIView()
var onReady: (() -> Void)?
var onFailure: ((Error) -> Void)?
private let mediaPlayer = VLCMediaPlayer()
override init() {
super.init()
mediaPlayer.delegate = self
view.backgroundColor = .black
}
func prepare(in viewController: UIViewController) {
mediaPlayer.drawable = view
}
func play(request: NativePlaybackRequest) {
let media = VLCMedia(url: request.playbackURL)
var headers = ["Referer": request.referer]
if let userAgent = request.userAgent {
headers["User-Agent"] = userAgent
}
let headerValue = headers
.map { "\($0.key): \($0.value)" }
.joined(separator: "\r\n")
media.addOption(":http-referrer=\(request.referer)")
if let userAgent = request.userAgent {
media.addOption(":http-user-agent=\(userAgent)")
}
media.addOption(":http-header=\(headerValue)")
mediaPlayer.media = media
mediaPlayer.play()
}
func stop() {
mediaPlayer.stop()
mediaPlayer.media = nil
}
}
extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
func mediaPlayerStateChanged(_ aNotification: Notification) {
switch mediaPlayer.state {
case .opening, .buffering, .playing:
onReady?()
case .error:
onFailure?(NativePlaybackError.backendUnavailable)
default:
break
}
}
}