mirror of
https://github.com/cranci1/Sora.git
synced 2026-01-11 20:10:24 +00:00
337 lines
No EOL
15 KiB
Swift
337 lines
No EOL
15 KiB
Swift
//
|
|
// M3U8StreamExtractor.swift
|
|
// Sora
|
|
//
|
|
// Created by Francesco on 30/04/25.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
enum M3U8StreamExtractorError: Error {
|
|
case networkError(Error)
|
|
case parsingError(String)
|
|
case noStreamFound
|
|
case invalidURL
|
|
|
|
var localizedDescription: String {
|
|
switch self {
|
|
case .networkError(let error):
|
|
return "Connection error: \(error.localizedDescription)"
|
|
case .parsingError(let message):
|
|
return "Stream parsing error: \(message)"
|
|
case .noStreamFound:
|
|
return "No compatible stream found in playlist"
|
|
case .invalidURL:
|
|
return "Stream URL is invalid"
|
|
}
|
|
}
|
|
}
|
|
|
|
class M3U8StreamExtractor {
|
|
|
|
// Enable verbose logging for development/testing
|
|
static var verboseLogging: Bool = true
|
|
|
|
/// Logs messages with a consistent format if verbose logging is enabled
|
|
/// - Parameters:
|
|
/// - message: The message to log
|
|
/// - function: The calling function (auto-filled)
|
|
/// - line: The line number (auto-filled)
|
|
private static func log(_ message: String, function: String = #function, line: Int = #line) {
|
|
if verboseLogging {
|
|
print("[M3U8Extractor:\(function):\(line)] \(message)")
|
|
}
|
|
}
|
|
|
|
/// Extracts the appropriate stream URL from a master M3U8 playlist based on quality preference
|
|
/// - Parameters:
|
|
/// - masterURL: The URL of the master M3U8 playlist
|
|
/// - headers: HTTP headers to use for the request
|
|
/// - preferredQuality: User's preferred quality ("Best", "High", "Medium", "Low")
|
|
/// - jsController: Optional reference to the JSController for header management
|
|
/// - completion: Completion handler with the result containing the selected stream URL and headers
|
|
static func extractStreamURL(
|
|
from masterURL: URL,
|
|
headers: [String: String],
|
|
preferredQuality: String,
|
|
jsController: JSController? = nil,
|
|
completion: @escaping (Result<(streamURL: URL, headers: [String: String]), Error>) -> Void
|
|
) {
|
|
log("Starting extraction from master playlist: \(masterURL.absoluteString)")
|
|
log("Preferred quality: \(preferredQuality)")
|
|
|
|
var requestHeaders = headers
|
|
|
|
// Use header manager if available
|
|
if let controller = jsController {
|
|
log("Using JSController for header management")
|
|
requestHeaders = controller.ensureStreamingHeaders(headers: headers, for: masterURL)
|
|
controller.logHeadersForRequest(headers: requestHeaders, url: masterURL, operation: "Extracting streams from")
|
|
} else {
|
|
log("JSController not provided, using original headers")
|
|
}
|
|
|
|
var request = URLRequest(url: masterURL)
|
|
|
|
// Add headers to the request
|
|
for (key, value) in requestHeaders {
|
|
request.addValue(value, forHTTPHeaderField: key)
|
|
}
|
|
|
|
// Add a unique request ID for tracking in logs
|
|
let requestID = UUID().uuidString.prefix(8)
|
|
log("Request ID: \(requestID)")
|
|
|
|
// Fetch the master playlist
|
|
log("Sending request to fetch master playlist")
|
|
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
|
// Handle network errors
|
|
if let error = error {
|
|
log("Network error: \(error.localizedDescription)")
|
|
completion(.failure(M3U8StreamExtractorError.networkError(error)))
|
|
return
|
|
}
|
|
|
|
// Log HTTP status for debugging
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
let statusCode = httpResponse.statusCode
|
|
log("HTTP Status: \(statusCode) for \(masterURL.absoluteString)")
|
|
|
|
if statusCode == 403 {
|
|
log("HTTP Error 403: Access Forbidden")
|
|
|
|
// Try to extract domain from URL for logging
|
|
let domain = masterURL.host ?? "unknown domain"
|
|
log("Access denied by server: \(domain)")
|
|
|
|
// Check if we have essential headers that might be missing/incorrect
|
|
let missingCriticalHeaders = ["Origin", "Referer", "User-Agent"].filter { requestHeaders[$0] == nil }
|
|
if !missingCriticalHeaders.isEmpty {
|
|
log("Missing critical headers: \(missingCriticalHeaders.joined(separator: ", "))")
|
|
}
|
|
|
|
// Since we got a 403, just fall back to the master URL directly
|
|
log("403 error - Falling back to master URL")
|
|
completion(.success((streamURL: masterURL, headers: requestHeaders)))
|
|
return
|
|
} else if statusCode >= 400 {
|
|
log("HTTP Error: \(statusCode)")
|
|
completion(.failure(M3U8StreamExtractorError.parsingError("HTTP Error: \(statusCode)")))
|
|
return
|
|
}
|
|
|
|
// Log response headers for debugging
|
|
log("Response Headers:")
|
|
for (key, value) in httpResponse.allHeaderFields {
|
|
log(" \(key): \(value)")
|
|
}
|
|
}
|
|
|
|
// Ensure we have data
|
|
guard let data = data else {
|
|
log("No data received")
|
|
completion(.failure(M3U8StreamExtractorError.parsingError("No data received")))
|
|
return
|
|
}
|
|
|
|
// Try to parse as string
|
|
guard let content = String(data: data, encoding: .utf8) else {
|
|
log("Failed to decode playlist content")
|
|
completion(.failure(M3U8StreamExtractorError.parsingError("Failed to decode playlist content")))
|
|
return
|
|
}
|
|
|
|
// Log a sample of the content (first 200 chars)
|
|
let contentPreview = String(content.prefix(200))
|
|
log("Playlist Content (preview): \(contentPreview)...")
|
|
|
|
// Count the number of lines in the content
|
|
let lineCount = content.components(separatedBy: .newlines).count
|
|
log("Playlist has \(lineCount) lines")
|
|
|
|
// Parse the M3U8 content to extract available streams
|
|
log("Parsing M3U8 content")
|
|
let streams = parseM3U8Content(content: content, baseURL: masterURL)
|
|
|
|
// Log the extracted streams
|
|
log("Extracted \(streams.count) streams from M3U8 playlist")
|
|
for (index, stream) in streams.enumerated() {
|
|
log("Stream #\(index + 1): \(stream.name), \(stream.resolution.width)x\(stream.resolution.height), URL: \(stream.url)")
|
|
}
|
|
|
|
if streams.isEmpty {
|
|
log("No streams found in playlist")
|
|
}
|
|
|
|
// Select the appropriate stream based on quality preference
|
|
log("Selecting stream with quality preference: \(preferredQuality)")
|
|
if let selectedURL = selectStream(streams: streams, preferredQuality: preferredQuality),
|
|
let url = URL(string: selectedURL) {
|
|
|
|
log("Selected stream URL: \(url.absoluteString)")
|
|
|
|
var finalHeaders = requestHeaders
|
|
|
|
// Use header manager to optimize headers for the selected stream if available
|
|
if let controller = jsController {
|
|
log("Optimizing headers for selected stream")
|
|
finalHeaders = controller.ensureStreamingHeaders(headers: requestHeaders, for: url)
|
|
controller.logHeadersForRequest(headers: finalHeaders, url: url, operation: "Selected stream")
|
|
}
|
|
|
|
// Return the selected stream URL along with the headers
|
|
log("Extraction successful")
|
|
completion(.success((streamURL: url, headers: finalHeaders)))
|
|
} else if !streams.isEmpty, let fallbackStream = streams.first, let url = URL(string: fallbackStream.url) {
|
|
// Fallback to first stream if preferred quality not found
|
|
log("Preferred quality '\(preferredQuality)' not found, falling back to: \(fallbackStream.name)")
|
|
|
|
var finalHeaders = requestHeaders
|
|
|
|
// Use header manager for fallback stream
|
|
if let controller = jsController {
|
|
log("Optimizing headers for fallback stream")
|
|
finalHeaders = controller.ensureStreamingHeaders(headers: requestHeaders, for: url)
|
|
controller.logHeadersForRequest(headers: finalHeaders, url: url, operation: "Fallback stream")
|
|
}
|
|
|
|
log("Fallback extraction successful")
|
|
completion(.success((streamURL: url, headers: finalHeaders)))
|
|
} else if streams.isEmpty {
|
|
// If the playlist doesn't contain any streams, use the master URL as fallback
|
|
log("No streams found in the playlist, using master URL as fallback")
|
|
log("Using master URL as fallback")
|
|
completion(.success((streamURL: masterURL, headers: requestHeaders)))
|
|
} else {
|
|
log("No suitable stream found")
|
|
completion(.failure(M3U8StreamExtractorError.noStreamFound))
|
|
}
|
|
}
|
|
|
|
task.resume()
|
|
log("Request started")
|
|
}
|
|
|
|
/// Parses M3U8 content to extract available streams
|
|
/// - Parameters:
|
|
/// - content: The M3U8 playlist content as string
|
|
/// - baseURL: The base URL of the playlist for resolving relative URLs
|
|
/// - Returns: Array of extracted streams with name, URL, and resolution
|
|
private static func parseM3U8Content(
|
|
content: String,
|
|
baseURL: URL
|
|
) -> [(name: String, url: String, resolution: (width: Int, height: Int))] {
|
|
let lines = content.components(separatedBy: .newlines)
|
|
var streams: [(name: String, url: String, resolution: (width: Int, height: Int))] = []
|
|
|
|
for (index, line) in lines.enumerated() {
|
|
// Look for the stream info tag
|
|
if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count {
|
|
// Extract resolution information
|
|
if let resolutionRange = line.range(of: "RESOLUTION="),
|
|
let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",")
|
|
?? line[resolutionRange.upperBound...].range(of: "\n") {
|
|
|
|
let resolutionPart = String(line[resolutionRange.upperBound..<resolutionEndRange.lowerBound])
|
|
let dimensions = resolutionPart.components(separatedBy: "x")
|
|
|
|
if dimensions.count == 2,
|
|
let width = Int(dimensions[0]),
|
|
let height = Int(dimensions[1]) {
|
|
|
|
// Get the URL from the next line
|
|
let nextLine = lines[index + 1].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
// Generate a quality name
|
|
let qualityName = getQualityName(for: height)
|
|
|
|
// Handle relative URLs
|
|
var streamURL = nextLine
|
|
if !nextLine.hasPrefix("http") && nextLine.contains(".m3u8") {
|
|
let baseURLString = baseURL.deletingLastPathComponent().absoluteString
|
|
streamURL = URL(string: nextLine, relativeTo: baseURL)?.absoluteString
|
|
?? baseURLString + "/" + nextLine
|
|
}
|
|
|
|
// Add the stream to our list
|
|
streams.append((
|
|
name: qualityName,
|
|
url: streamURL,
|
|
resolution: (width: width, height: height)
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return streams
|
|
}
|
|
|
|
/// Selects a stream based on the user's quality preference
|
|
/// - Parameters:
|
|
/// - streams: Array of available streams
|
|
/// - preferredQuality: User's preferred quality
|
|
/// - Returns: URL of the selected stream, or nil if no suitable stream was found
|
|
private static func selectStream(
|
|
streams: [(name: String, url: String, resolution: (width: Int, height: Int))],
|
|
preferredQuality: String
|
|
) -> String? {
|
|
guard !streams.isEmpty else { return nil }
|
|
|
|
// Sort streams by resolution (height) in descending order
|
|
let sortedStreams = streams.sorted { $0.resolution.height > $1.resolution.height }
|
|
|
|
switch preferredQuality {
|
|
case "Best":
|
|
// Return the highest quality stream
|
|
return sortedStreams.first?.url
|
|
|
|
case "High":
|
|
// Return a high quality stream (720p or higher, but not the highest)
|
|
let highStreams = sortedStreams.filter { $0.resolution.height >= 720 }
|
|
if highStreams.count > 1 {
|
|
return highStreams[1].url // Second highest if available
|
|
} else if !highStreams.isEmpty {
|
|
return highStreams[0].url // Highest if only one high quality stream
|
|
} else if !sortedStreams.isEmpty {
|
|
return sortedStreams.first?.url // Fallback to highest available
|
|
}
|
|
|
|
case "Medium":
|
|
// Return a medium quality stream (between 480p and 720p)
|
|
let mediumStreams = sortedStreams.filter {
|
|
$0.resolution.height >= 480 && $0.resolution.height < 720
|
|
}
|
|
if !mediumStreams.isEmpty {
|
|
return mediumStreams.first?.url
|
|
} else if sortedStreams.count > 1 {
|
|
let medianIndex = sortedStreams.count / 2
|
|
return sortedStreams[medianIndex].url // Return median quality as fallback
|
|
} else if !sortedStreams.isEmpty {
|
|
return sortedStreams.first?.url // Fallback to highest available
|
|
}
|
|
|
|
case "Low":
|
|
// Return the lowest quality stream
|
|
return sortedStreams.last?.url
|
|
|
|
default:
|
|
// Default to best quality
|
|
return sortedStreams.first?.url
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Generates a quality name based on resolution height
|
|
/// - Parameter height: The vertical resolution (height) of the stream
|
|
/// - Returns: A human-readable quality name
|
|
private static func getQualityName(for height: Int) -> String {
|
|
switch height {
|
|
case 1080...: return "\(height)p (FHD)"
|
|
case 720..<1080: return "\(height)p (HD)"
|
|
case 480..<720: return "\(height)p (SD)"
|
|
default: return "\(height)p"
|
|
}
|
|
}
|
|
} |