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
|
|
@ -4,3 +4,4 @@
|
|||
{"id":"int-a86e17e0","kind":"field_change","created_at":"2026-05-25T02:34:54.605755Z","actor":"dirtydishes","issue_id":"dreamio-evt","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented debug-only WKWebView inspection, token-safe playback diagnostics, navigation logging, validation build, and turn documentation."}}
|
||||
{"id":"int-4d73c126","kind":"field_change","created_at":"2026-05-25T03:20:17.439589Z","actor":"dirtydishes","issue_id":"dreamio-l68","extra":{"field":"status","new_value":"closed","old_value":"in_progress","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."}}
|
||||
{"id":"int-3dbe205a","kind":"field_change","created_at":"2026-05-25T03:23:00.515861Z","actor":"dirtydishes","issue_id":"dreamio-2lp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed Swift raw string escaping and guarded MobileVLCKit import for builds before pod install."}}
|
||||
{"id":"int-23df9e14","kind":"field_change","created_at":"2026-05-25T03:41:03.811099Z","actor":"dirtydishes","issue_id":"dreamio-vxs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Resolved native playback stream URLs before opening VLC, added resolver selection tests, and documented validation limits."}}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
{"_type":"issue","id":"dreamio-vxs","title":"Resolve final media URLs before native playback","description":"Dreamio native playback can pass addon resolver URLs into VLC instead of the final direct media URL. Resolve known Stremio addon stream responses before presenting the native player, preserve needed headers, and make startup failure recoverable.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:36:14Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:41:04Z","started_at":"2026-05-25T03:36:19Z","closed_at":"2026-05-25T03:41:04Z","close_reason":"Resolved native playback stream URLs before opening VLC, added resolver selection tests, and documented validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-2lp","title":"Fix native playback build blockers","description":"Correct Swift string escaping for the injected stream bridge and allow the VLC backend source to compile before MobileVLCKit is installed by guarding the import with canImport.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:22:52Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:23:00Z","started_at":"2026-05-25T03:23:00Z","closed_at":"2026-05-25T03:23:00Z","close_reason":"Fixed Swift raw string escaping and guarded MobileVLCKit import for builds before pod install.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_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}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */; };
|
||||
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 */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
|
|
@ -26,6 +27,7 @@
|
|||
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlaybackBackend.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
|
@ -62,6 +64,7 @@
|
|||
6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */,
|
||||
6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */,
|
||||
6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */,
|
||||
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,
|
||||
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,
|
||||
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,
|
||||
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,
|
||||
|
|
@ -142,6 +145,7 @@
|
|||
6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */,
|
||||
6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */,
|
||||
6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */,
|
||||
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */,
|
||||
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */,
|
||||
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */,
|
||||
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
Task { [weak self] in
|
||||
await self?.resolveAndPresentNativePlayback(request)
|
||||
}
|
||||
}
|
||||
|
||||
@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)")
|
||||
}
|
||||
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
|
||||
|
|
|
|||
73
Tests/StreamResolverTests.swift
Normal file
73
Tests/StreamResolverTests.swift
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import Foundation
|
||||
|
||||
@main
|
||||
struct StreamResolverTests {
|
||||
static func main() {
|
||||
testClassifierPrefersObservedDirectFile()
|
||||
testResolverSelectsUnsupportedDirectURLAndHeaders()
|
||||
testResolverRejectsHLSOnlyResponse()
|
||||
print("StreamResolverTests passed")
|
||||
}
|
||||
|
||||
private static func testClassifierPrefersObservedDirectFile() {
|
||||
let body: [String: Any] = [
|
||||
"url": "https://cdn.example.test/movie.mkv?token=secret",
|
||||
"resolverUrl": "https://addon.debridio.com/play/example"
|
||||
]
|
||||
let candidate = StreamCandidate(messageBody: body)!
|
||||
let request = StreamClassifier.playbackRequest(from: candidate, userAgent: "DreamioTest/1")!
|
||||
|
||||
assertEqual(request.playbackURL.absoluteString, "https://cdn.example.test/movie.mkv?token=secret")
|
||||
assertEqual(request.headers["Referer"], "https://web.stremio.com/")
|
||||
assertEqual(request.headers["User-Agent"], "DreamioTest/1")
|
||||
}
|
||||
|
||||
private static func testResolverSelectsUnsupportedDirectURLAndHeaders() {
|
||||
let payload: [String: Any] = [
|
||||
"streams": [
|
||||
[
|
||||
"url": "https://cdn.example.test/trailer.mp4"
|
||||
],
|
||||
[
|
||||
"externalUrl": "https://cdn.example.test/movie.mkv?signature=secret",
|
||||
"behaviorHints": [
|
||||
"proxyHeaders": [
|
||||
"request": [
|
||||
"Referer": "https://resolver.example.test/",
|
||||
"User-Agent": "ResolverAgent/1"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
let stream = StremioStreamResolver.bestPlayableStream(
|
||||
in: payload,
|
||||
fallbackHeaders: ["Referer": "https://web.stremio.com/"]
|
||||
)!
|
||||
|
||||
assertEqual(stream.playbackURL.absoluteString, "https://cdn.example.test/movie.mkv?signature=secret")
|
||||
assertEqual(stream.headers["Referer"], "https://resolver.example.test/")
|
||||
assertEqual(stream.headers["User-Agent"], "ResolverAgent/1")
|
||||
}
|
||||
|
||||
private static func testResolverRejectsHLSOnlyResponse() {
|
||||
let payload: [String: Any] = [
|
||||
"streams": [
|
||||
["url": "https://cdn.example.test/live.m3u8"]
|
||||
]
|
||||
]
|
||||
|
||||
let stream = StremioStreamResolver.bestPlayableStream(
|
||||
in: payload,
|
||||
fallbackHeaders: ["Referer": "https://web.stremio.com/"]
|
||||
)
|
||||
|
||||
assert(stream == nil, "Expected HLS-only resolver response to stay out of native playback")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
196
docs/turns/2026-05-25-fix-native-playback-resolution.html
Normal file
196
docs/turns/2026-05-25-fix-native-playback-resolution.html
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue