mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
fix native playback stream resolution
This commit is contained in:
parent
b15e4d640e
commit
d46004a98e
11 changed files with 588 additions and 16 deletions
|
|
@ -52,6 +52,7 @@ final class DreamioWebViewController: UIViewController {
|
|||
private var progressObservation: NSKeyValueObservation?
|
||||
private var userAgent: String?
|
||||
private var lastNativePlaybackURL: URL?
|
||||
private let streamResolver: StreamResolving = StremioStreamResolver()
|
||||
|
||||
private static let streamCandidateScript = WKUserScript(
|
||||
source: #"""
|
||||
|
|
@ -103,6 +104,7 @@ final class DreamioWebViewController: UIViewController {
|
|||
if (!looksNative(url)) {
|
||||
return;
|
||||
}
|
||||
stopNativeHandledMedia(element);
|
||||
try {
|
||||
window.webkit.messageHandlers.dreamioStreamCandidate.postMessage({
|
||||
url,
|
||||
|
|
@ -114,6 +116,23 @@ final class DreamioWebViewController: UIViewController {
|
|||
} catch (_) {}
|
||||
};
|
||||
|
||||
const stopNativeHandledMedia = (element) => {
|
||||
const media = element instanceof HTMLVideoElement
|
||||
? element
|
||||
: element && element.parentElement instanceof HTMLVideoElement
|
||||
? element.parentElement
|
||||
: null;
|
||||
if (!media) {
|
||||
return;
|
||||
}
|
||||
try { media.pause(); } catch (_) {}
|
||||
try { media.removeAttribute("src"); } catch (_) {}
|
||||
try {
|
||||
media.querySelectorAll("source").forEach((source) => source.removeAttribute("src"));
|
||||
} catch (_) {}
|
||||
try { media.load(); } catch (_) {}
|
||||
};
|
||||
|
||||
const inspectMedia = (node) => {
|
||||
if (!node) {
|
||||
return;
|
||||
|
|
@ -331,21 +350,61 @@ final class DreamioWebViewController: UIViewController {
|
|||
return
|
||||
}
|
||||
|
||||
if lastNativePlaybackURL == request.playbackURL {
|
||||
let duplicateKey = request.resolverURL ?? request.playbackURL
|
||||
if lastNativePlaybackURL == duplicateKey {
|
||||
return
|
||||
}
|
||||
lastNativePlaybackURL = request.playbackURL
|
||||
lastNativePlaybackURL = duplicateKey
|
||||
|
||||
#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
|
||||
Task { [weak self] in
|
||||
await self?.resolveAndPresentNativePlayback(request)
|
||||
}
|
||||
present(player, animated: true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {
|
||||
do {
|
||||
let resolved = try await streamResolver.resolve(request: request)
|
||||
#if DEBUG
|
||||
print("[DreamioStreamResolver] source=\(resolved.source) playback=\(URLRedactor.redactedURLString(resolved.playbackURL.absoluteString))")
|
||||
#endif
|
||||
let resolvedRequest = NativePlaybackRequest(
|
||||
playbackURL: resolved.playbackURL,
|
||||
observedURL: request.observedURL,
|
||||
resolverURL: request.resolverURL,
|
||||
pageURL: request.pageURL,
|
||||
userAgent: request.userAgent,
|
||||
referer: request.referer,
|
||||
headers: resolved.headers,
|
||||
classification: request.classification
|
||||
)
|
||||
let player = NativePlayerViewController(request: resolvedRequest)
|
||||
player.onDismiss = { [weak self] in
|
||||
self?.lastNativePlaybackURL = nil
|
||||
}
|
||||
present(player, animated: true)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")")
|
||||
#endif
|
||||
lastNativePlaybackURL = nil
|
||||
showNativePlaybackResolutionFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func showNativePlaybackResolutionFailure(_ error: Error) {
|
||||
let alert = UIAlertController(
|
||||
title: "Could not open stream",
|
||||
message: error.localizedDescription,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "Close", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
|
|
|||
|
|
@ -12,11 +12,17 @@ protocol NativePlaybackBackend: AnyObject {
|
|||
|
||||
enum NativePlaybackError: LocalizedError {
|
||||
case backendUnavailable
|
||||
case startupTimedOut
|
||||
case playbackFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .backendUnavailable:
|
||||
return "Native playback is not available in this build."
|
||||
case .startupTimedOut:
|
||||
return "Native playback did not start before the timeout."
|
||||
case .playbackFailed:
|
||||
return "VLC reported a playback error for this stream."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import UIKit
|
|||
final class NativePlayerViewController: UIViewController {
|
||||
private let request: NativePlaybackRequest
|
||||
private var backend: NativePlaybackBackend
|
||||
private var startupTimer: Timer?
|
||||
var onDismiss: (() -> Void)?
|
||||
|
||||
private let loadingView: UIActivityIndicatorView = {
|
||||
|
|
@ -66,11 +67,13 @@ final class NativePlayerViewController: UIViewController {
|
|||
view.backgroundColor = .black
|
||||
configureBackend()
|
||||
configureLayout()
|
||||
startStartupTimer()
|
||||
backend.play(request: request)
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
startupTimer?.invalidate()
|
||||
backend.stop()
|
||||
onDismiss?()
|
||||
}
|
||||
|
|
@ -80,17 +83,27 @@ final class NativePlayerViewController: UIViewController {
|
|||
backend.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
backend.onReady = { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
self?.startupTimer?.invalidate()
|
||||
self?.loadingView.stopAnimating()
|
||||
self?.loadingView.isHidden = true
|
||||
}
|
||||
}
|
||||
backend.onFailure = { [weak self] error in
|
||||
DispatchQueue.main.async {
|
||||
self?.startupTimer?.invalidate()
|
||||
self?.showFailure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startStartupTimer() {
|
||||
startupTimer?.invalidate()
|
||||
startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in
|
||||
self?.backend.stop()
|
||||
self?.showFailure(NativePlaybackError.startupTimedOut)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureLayout() {
|
||||
view.addSubview(backend.view)
|
||||
view.addSubview(loadingView)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ struct NativePlaybackRequest {
|
|||
let pageURL: URL?
|
||||
let userAgent: String?
|
||||
let referer: String
|
||||
let headers: [String: String]
|
||||
let classification: StreamClassification
|
||||
}
|
||||
|
||||
|
|
@ -75,16 +76,40 @@ enum StreamClassifier {
|
|||
}
|
||||
|
||||
return NativePlaybackRequest(
|
||||
playbackURL: candidate.resolverURL ?? candidate.observedURL,
|
||||
playbackURL: candidate.observedURL,
|
||||
observedURL: candidate.observedURL,
|
||||
resolverURL: candidate.resolverURL,
|
||||
pageURL: candidate.pageURL,
|
||||
userAgent: userAgent,
|
||||
referer: referer,
|
||||
headers: Self.defaultHeaders(userAgent: userAgent),
|
||||
classification: classification
|
||||
)
|
||||
}
|
||||
|
||||
static func defaultHeaders(userAgent: String?) -> [String: String] {
|
||||
var headers = ["Referer": referer]
|
||||
if let userAgent, !userAgent.isEmpty {
|
||||
headers["User-Agent"] = userAgent
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
static func isDirectPlayableFileURL(_ url: URL) -> Bool {
|
||||
let container = containerGuess(for: url, resolverURL: nil)
|
||||
return [.mp4, .mkv, .avi, .webm].contains(container)
|
||||
}
|
||||
|
||||
static func isWebKitCompatibleURL(_ url: URL) -> Bool {
|
||||
let container = containerGuess(for: url, resolverURL: nil)
|
||||
return container == .hls || container == .mp4
|
||||
}
|
||||
|
||||
static func isKnownResolverURL(_ url: URL) -> Bool {
|
||||
matches(url, host: "addon.debridio.com", pathPrefix: "/play/")
|
||||
|| matches(url, host: "torrentio.strem.fun", pathPrefix: "/resolve/")
|
||||
}
|
||||
|
||||
static func classify(candidate: StreamCandidate) -> StreamClassification {
|
||||
let observed = candidate.observedURL
|
||||
let resolver = candidate.resolverURL
|
||||
|
|
|
|||
167
Dreamio/StreamResolver.swift
Normal file
167
Dreamio/StreamResolver.swift
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import Foundation
|
||||
|
||||
struct ResolvedNativeStream {
|
||||
let playbackURL: URL
|
||||
let headers: [String: String]
|
||||
let source: String
|
||||
}
|
||||
|
||||
enum StreamResolverError: LocalizedError {
|
||||
case noResolverURL
|
||||
case httpStatus(Int)
|
||||
case emptyResponse
|
||||
case invalidResponse
|
||||
case noPlayableStream
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noResolverURL:
|
||||
return "Dreamio could not find an addon resolver URL for this stream."
|
||||
case let .httpStatus(status):
|
||||
return "The stream resolver returned HTTP \(status)."
|
||||
case .emptyResponse:
|
||||
return "The stream resolver returned an empty response."
|
||||
case .invalidResponse:
|
||||
return "The stream resolver returned data Dreamio could not parse."
|
||||
case .noPlayableStream:
|
||||
return "The resolver did not return a direct playable media URL."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol StreamResolving {
|
||||
func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream
|
||||
}
|
||||
|
||||
final class StremioStreamResolver: StreamResolving {
|
||||
private let session: URLSession
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream {
|
||||
if StreamClassifier.isDirectPlayableFileURL(request.observedURL) {
|
||||
return ResolvedNativeStream(
|
||||
playbackURL: request.observedURL,
|
||||
headers: request.headers,
|
||||
source: "observed-direct-file"
|
||||
)
|
||||
}
|
||||
|
||||
let possibleResolverURL = request.resolverURL ?? request.observedURL
|
||||
guard StreamClassifier.isKnownResolverURL(possibleResolverURL) else {
|
||||
throw StreamResolverError.noResolverURL
|
||||
}
|
||||
|
||||
var urlRequest = URLRequest(url: possibleResolverURL)
|
||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.headers.forEach { key, value in
|
||||
urlRequest.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
let (data, response) = try await session.data(for: urlRequest)
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
!(200...299).contains(httpResponse.statusCode) {
|
||||
throw StreamResolverError.httpStatus(httpResponse.statusCode)
|
||||
}
|
||||
if let finalURL = response.url, StreamClassifier.isDirectPlayableFileURL(finalURL) {
|
||||
return ResolvedNativeStream(
|
||||
playbackURL: finalURL,
|
||||
headers: request.headers,
|
||||
source: "resolver-redirect"
|
||||
)
|
||||
}
|
||||
guard !data.isEmpty else {
|
||||
throw StreamResolverError.emptyResponse
|
||||
}
|
||||
|
||||
let payload = try parsePayload(from: data)
|
||||
guard let stream = Self.bestPlayableStream(in: payload, fallbackHeaders: request.headers) else {
|
||||
throw StreamResolverError.noPlayableStream
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
private func parsePayload(from data: Data) throws -> Any {
|
||||
do {
|
||||
return try JSONSerialization.jsonObject(with: data)
|
||||
} catch {
|
||||
throw StreamResolverError.invalidResponse
|
||||
}
|
||||
}
|
||||
|
||||
static func bestPlayableStream(in payload: Any, fallbackHeaders: [String: String]) -> ResolvedNativeStream? {
|
||||
let streams = streamDictionaries(in: payload)
|
||||
let candidates = streams.compactMap { stream -> ResolvedNativeStream? in
|
||||
guard let url = directURL(in: stream) else {
|
||||
return nil
|
||||
}
|
||||
guard StreamClassifier.isDirectPlayableFileURL(url) else {
|
||||
return nil
|
||||
}
|
||||
return ResolvedNativeStream(
|
||||
playbackURL: url,
|
||||
headers: mergedHeaders(fallbackHeaders: fallbackHeaders, stream: stream),
|
||||
source: "resolver-json"
|
||||
)
|
||||
}
|
||||
|
||||
return candidates.first { !StreamClassifier.isWebKitCompatibleURL($0.playbackURL) } ?? candidates.first
|
||||
}
|
||||
|
||||
private static func streamDictionaries(in payload: Any) -> [[String: Any]] {
|
||||
if let dictionary = payload as? [String: Any],
|
||||
let streams = dictionary["streams"] as? [[String: Any]] {
|
||||
return streams
|
||||
}
|
||||
if let streams = payload as? [[String: Any]] {
|
||||
return streams
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private static func directURL(in stream: [String: Any]) -> URL? {
|
||||
let fields = ["url", "externalUrl", "externalURL", "file", "streamUrl", "streamURL"]
|
||||
for field in fields {
|
||||
if let value = stream[field] as? String,
|
||||
let url = URL(string: value),
|
||||
["http", "https"].contains(url.scheme?.lowercased()) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func mergedHeaders(fallbackHeaders: [String: String], stream: [String: Any]) -> [String: String] {
|
||||
var headers = fallbackHeaders
|
||||
headerDictionaries(in: stream).forEach { headerDictionary in
|
||||
headerDictionary.forEach { key, value in
|
||||
headers[key] = value
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
private static func headerDictionaries(in stream: [String: Any]) -> [[String: String]] {
|
||||
var dictionaries: [[String: String]] = []
|
||||
|
||||
if let headers = stream["headers"] as? [String: String] {
|
||||
dictionaries.append(headers)
|
||||
}
|
||||
if let requestHeaders = stream["requestHeaders"] as? [String: String] {
|
||||
dictionaries.append(requestHeaders)
|
||||
}
|
||||
if let behaviorHints = stream["behaviorHints"] as? [String: Any] {
|
||||
if let headers = behaviorHints["headers"] as? [String: String] {
|
||||
dictionaries.append(headers)
|
||||
}
|
||||
if let proxyHeaders = behaviorHints["proxyHeaders"] as? [String: Any],
|
||||
let request = proxyHeaders["request"] as? [String: String] {
|
||||
dictionaries.append(request)
|
||||
}
|
||||
}
|
||||
|
||||
return dictionaries
|
||||
}
|
||||
}
|
||||
|
|
@ -30,21 +30,21 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
func play(request: NativePlaybackRequest) {
|
||||
#if canImport(MobileVLCKit)
|
||||
let media = VLCMedia(url: request.playbackURL)
|
||||
var headers = ["Referer": request.referer]
|
||||
if let userAgent = request.userAgent {
|
||||
headers["User-Agent"] = userAgent
|
||||
}
|
||||
|
||||
let headerValue = headers
|
||||
let headerValue = request.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)")
|
||||
if !headerValue.isEmpty {
|
||||
media.addOption(":http-header=\(headerValue)")
|
||||
}
|
||||
|
||||
mediaPlayer.media = media
|
||||
#if DEBUG
|
||||
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
|
||||
#endif
|
||||
mediaPlayer.play()
|
||||
#else
|
||||
onFailure?(NativePlaybackError.backendUnavailable)
|
||||
|
|
@ -54,6 +54,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
func stop() {
|
||||
#if canImport(MobileVLCKit)
|
||||
mediaPlayer.stop()
|
||||
mediaPlayer.drawable = nil
|
||||
mediaPlayer.media = nil
|
||||
#endif
|
||||
}
|
||||
|
|
@ -62,14 +63,40 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
#if canImport(MobileVLCKit)
|
||||
extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
|
||||
func mediaPlayerStateChanged(_ aNotification: Notification) {
|
||||
#if DEBUG
|
||||
print("[DreamioVLC] state=\(stateName(mediaPlayer.state))")
|
||||
#endif
|
||||
switch mediaPlayer.state {
|
||||
case .opening, .buffering, .playing:
|
||||
case .buffering, .playing:
|
||||
onReady?()
|
||||
case .error:
|
||||
onFailure?(NativePlaybackError.backendUnavailable)
|
||||
onFailure?(NativePlaybackError.playbackFailed)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func stateName(_ state: VLCMediaPlayerState) -> String {
|
||||
switch state {
|
||||
case .opening:
|
||||
return "opening"
|
||||
case .buffering:
|
||||
return "buffering"
|
||||
case .playing:
|
||||
return "playing"
|
||||
case .ended:
|
||||
return "ended"
|
||||
case .stopped:
|
||||
return "stopped"
|
||||
case .error:
|
||||
return "error"
|
||||
case .paused:
|
||||
return "paused"
|
||||
case .esAdded:
|
||||
return "elementary-stream-added"
|
||||
@unknown default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue