dreamio/Dreamio/StreamCandidate.swift

208 lines
6.8 KiB
Swift

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
)
}
}
}