mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
add native debrid stream playback
This commit is contained in:
parent
3df2e2b833
commit
d28540ce98
12 changed files with 936 additions and 62 deletions
|
|
@ -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(
|
||||
|
|
|
|||
22
Dreamio/NativePlaybackBackend.swift
Normal file
22
Dreamio/NativePlaybackBackend.swift
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
134
Dreamio/NativePlayerViewController.swift
Normal file
134
Dreamio/NativePlayerViewController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
208
Dreamio/StreamCandidate.swift
Normal file
208
Dreamio/StreamCandidate.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
Dreamio/VLCNativePlaybackBackend.swift
Normal file
58
Dreamio/VLCNativePlaybackBackend.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue