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
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue