diff --git a/Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift b/Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift new file mode 100644 index 0000000..621152c --- /dev/null +++ b/Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift @@ -0,0 +1,527 @@ +// +// JSController+Downloader.swift +// Sora +// +// Created by doomsboygaming on 6/13/25 +// + +import Foundation +import SwiftUI +import AVFoundation + + +struct DownloadRequest { + let url: URL + let headers: [String: String] + let title: String? + let imageURL: URL? + let isEpisode: Bool + let showTitle: String? + let season: Int? + let episode: Int? + let subtitleURL: URL? + let showPosterURL: URL? + + init(url: URL, headers: [String: String], title: String? = nil, imageURL: URL? = nil, + isEpisode: Bool = false, showTitle: String? = nil, season: Int? = nil, + episode: Int? = nil, subtitleURL: URL? = nil, showPosterURL: URL? = nil) { + self.url = url + self.headers = headers + self.title = title + self.imageURL = imageURL + self.isEpisode = isEpisode + self.showTitle = showTitle + self.season = season + self.episode = episode + self.subtitleURL = subtitleURL + self.showPosterURL = showPosterURL + } +} + +struct QualityOption { + let name: String + let url: String + let height: Int? + + init(name: String, url: String, height: Int? = nil) { + self.name = name + self.url = url + self.height = height + } +} + +extension JSController { + + func downloadWithM3U8Support(url: URL, headers: [String: String], title: String? = nil, + imageURL: URL? = nil, isEpisode: Bool = false, + showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, + subtitleURL: URL? = nil, showPosterURL: URL? = nil, + completionHandler: ((Bool, String) -> Void)? = nil) { + + let request = DownloadRequest( + url: url, headers: headers, title: title, imageURL: imageURL, + isEpisode: isEpisode, showTitle: showTitle, season: season, + episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL + ) + + logDownloadStart(request: request) + + if url.absoluteString.contains(".m3u8") { + handleM3U8Download(request: request, completionHandler: completionHandler) + } else { + handleDirectDownload(request: request, completionHandler: completionHandler) + } + } + + + private func handleM3U8Download(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) { + let preferredQuality = DownloadQualityPreference.current.rawValue + logM3U8Detection(preferredQuality: preferredQuality) + + parseM3U8(url: request.url, headers: request.headers) { [weak self] qualities in + DispatchQueue.main.async { + guard let self = self else { return } + + if qualities.isEmpty { + self.logM3U8NoQualities() + self.downloadWithOriginalMethod(request: request, completionHandler: completionHandler) + return + } + + self.logM3U8QualitiesFound(qualities: qualities) + let selectedQuality = self.selectQualityBasedOnPreference(qualities: qualities, preferredQuality: preferredQuality) + self.logM3U8QualitySelected(quality: selectedQuality) + + if let qualityURL = URL(string: selectedQuality.url) { + let qualityRequest = DownloadRequest( + url: qualityURL, headers: request.headers, title: request.title, + imageURL: request.imageURL, isEpisode: request.isEpisode, + showTitle: request.showTitle, season: request.season, + episode: request.episode, subtitleURL: request.subtitleURL, + showPosterURL: request.showPosterURL + ) + self.downloadWithOriginalMethod(request: qualityRequest, completionHandler: completionHandler) + } else { + self.logM3U8InvalidURL() + self.downloadWithOriginalMethod(request: request, completionHandler: completionHandler) + } + } + } + } + + private func handleDirectDownload(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) { + logDirectDownload() + + let urlString = request.url.absoluteString.lowercased() + if urlString.contains(".mp4") || urlString.contains("mp4") { + logMP4Detection() + downloadMP4(request: request, completionHandler: completionHandler) + } else { + downloadWithOriginalMethod(request: request, completionHandler: completionHandler) + } + } + + + func downloadMP4(url: URL, headers: [String: String], title: String? = nil, + imageURL: URL? = nil, isEpisode: Bool = false, + showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, + subtitleURL: URL? = nil, showPosterURL: URL? = nil, + completionHandler: ((Bool, String) -> Void)? = nil) { + + let request = DownloadRequest( + url: url, headers: headers, title: title, imageURL: imageURL, + isEpisode: isEpisode, showTitle: showTitle, season: season, + episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL + ) + + downloadMP4(request: request, completionHandler: completionHandler) + } + + private func downloadMP4(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) { + guard validateURL(request.url) else { + completionHandler?(false, "Invalid URL scheme") + return + } + + guard let downloadSession = downloadURLSession else { + completionHandler?(false, "Download session not available") + return + } + + let metadata = createAssetMetadata(from: request) + let downloadType: DownloadType = request.isEpisode ? .episode : .movie + let downloadID = UUID() + + let asset = AVURLAsset(url: request.url, options: [ + "AVURLAssetHTTPHeaderFieldsKey": request.headers + ]) + + guard let downloadTask = downloadSession.makeAssetDownloadTask( + asset: asset, + assetTitle: request.title ?? request.url.lastPathComponent, + assetArtworkData: nil, + options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000] + ) else { + completionHandler?(false, "Failed to create download task") + return + } + + let activeDownload = createActiveDownload( + id: downloadID, request: request, asset: asset, + downloadTask: downloadTask, downloadType: downloadType, metadata: metadata + ) + + addActiveDownload(activeDownload, task: downloadTask) + setupMP4ProgressObservation(for: downloadTask, downloadID: downloadID) + downloadTask.resume() + + postDownloadNotification() + completionHandler?(true, "Download started") + } + + + private func parseM3U8(url: URL, headers: [String: String], completion: @escaping ([QualityOption]) -> Void) { + var request = URLRequest(url: url) + for (key, value) in headers { + request.addValue(value, forHTTPHeaderField: key) + } + + logM3U8FetchStart(url: url) + + URLSession.shared.dataTask(with: request) { data, response, error in + if let httpResponse = response as? HTTPURLResponse { + self.logHTTPStatus(httpResponse.statusCode, for: url) + if httpResponse.statusCode >= 400 { + completion([]) + return + } + } + + if let error = error { + self.logM3U8FetchError(error) + completion([]) + return + } + + guard let data = data, let content = String(data: data, encoding: .utf8) else { + self.logM3U8DecodeError() + completion([]) + return + } + + self.logM3U8FetchSuccess(dataSize: data.count) + let qualities = self.parseM3U8Content(content: content, baseURL: url) + completion(qualities) + }.resume() + } + + private func parseM3U8Content(content: String, baseURL: URL) -> [QualityOption] { + let lines = content.components(separatedBy: .newlines) + logM3U8ParseStart(lineCount: lines.count) + + var qualities: [QualityOption] = [] + qualities.append(QualityOption(name: "Auto (Recommended)", url: baseURL.absoluteString)) + + for (index, line) in lines.enumerated() { + if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count { + if let qualityOption = parseStreamInfoLine(line: line, nextLine: lines[index + 1], baseURL: baseURL) { + if !qualities.contains(where: { $0.name == qualityOption.name }) { + qualities.append(qualityOption) + logM3U8QualityAdded(quality: qualityOption) + } + } + } + } + + logM3U8ParseComplete(qualityCount: qualities.count - 1) // -1 for Auto + return qualities + } + + private func parseStreamInfoLine(line: String, nextLine: String, baseURL: URL) -> QualityOption? { + guard let resolutionRange = line.range(of: "RESOLUTION="), + let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",") + ?? line[resolutionRange.upperBound...].range(of: "\n") else { + return nil + } + + let resolutionPart = String(line[resolutionRange.upperBound.. 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" + } + } + + private func resolveQualityURL(_ urlString: String, baseURL: URL) -> String { + if urlString.hasPrefix("http") { + return urlString + } + + if urlString.contains(".m3u8") { + return URL(string: urlString, relativeTo: baseURL)?.absoluteString + ?? baseURL.deletingLastPathComponent().absoluteString + "/" + urlString + } + + return urlString + } + + + private func selectQualityBasedOnPreference(qualities: [QualityOption], preferredQuality: String) -> QualityOption { + guard qualities.count > 1 else { + logQualitySelectionSingle() + return qualities[0] + } + + let (autoQuality, sortedQualities) = categorizeQualities(qualities: qualities) + logQualitySelectionStart(preference: preferredQuality, sortedCount: sortedQualities.count) + + let selected = selectQualityByPreference( + preference: preferredQuality, + sortedQualities: sortedQualities, + autoQuality: autoQuality, + fallback: qualities[0] + ) + + logQualitySelectionResult(quality: selected, preference: preferredQuality) + return selected + } + + private func categorizeQualities(qualities: [QualityOption]) -> (auto: QualityOption?, sorted: [QualityOption]) { + let autoQuality = qualities.first { $0.name.contains("Auto") } + let nonAutoQualities = qualities.filter { !$0.name.contains("Auto") } + + let sortedQualities = nonAutoQualities.sorted { first, second in + let firstHeight = first.height ?? extractHeight(from: first.name) + let secondHeight = second.height ?? extractHeight(from: second.name) + return firstHeight > secondHeight + } + + return (autoQuality, sortedQualities) + } + + private func selectQualityByPreference(preference: String, sortedQualities: [QualityOption], + autoQuality: QualityOption?, fallback: QualityOption) -> QualityOption { + switch preference { + case "Best": + return sortedQualities.first ?? fallback + case "High": + return findQualityByType(["720p", "HD"], in: sortedQualities) ?? sortedQualities.first ?? fallback + case "Medium": + return findQualityByType(["480p", "SD"], in: sortedQualities) + ?? (sortedQualities.isEmpty ? fallback : sortedQualities[sortedQualities.count / 2]) + case "Low": + return sortedQualities.last ?? fallback + default: + return autoQuality ?? fallback + } + } + + private func findQualityByType(_ types: [String], in qualities: [QualityOption]) -> QualityOption? { + return qualities.first { quality in + types.contains { quality.name.contains($0) } + } + } + + private func extractHeight(from qualityName: String) -> Int { + return Int(qualityName.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0 + } + + + private func validateURL(_ url: URL) -> Bool { + return url.scheme == "http" || url.scheme == "https" + } + + private func createAssetMetadata(from request: DownloadRequest) -> AssetMetadata? { + guard let title = request.title else { return nil } + + return AssetMetadata( + title: title, + posterURL: request.imageURL, + showTitle: request.showTitle, + season: request.season, + episode: request.episode, + showPosterURL: request.showPosterURL ?? request.imageURL + ) + } + + private func createActiveDownload(id: UUID, request: DownloadRequest, asset: AVURLAsset, + downloadTask: AVAssetDownloadTask? = nil, urlSessionTask: URLSessionDownloadTask? = nil, + downloadType: DownloadType, metadata: AssetMetadata?) -> JSActiveDownload { + return JSActiveDownload( + id: id, + originalURL: request.url, + progress: 0.0, + task: downloadTask, + urlSessionTask: urlSessionTask, + queueStatus: .downloading, + type: downloadType, + metadata: metadata, + title: request.title, + imageURL: request.imageURL, + subtitleURL: request.subtitleURL, + asset: asset, + headers: request.headers, + module: nil + ) + } + + private func addActiveDownload(_ download: JSActiveDownload, task: URLSessionTask) { + activeDownloads.append(download) + activeDownloadMap[task] = download.id + } + + private func postDownloadNotification() { + DispatchQueue.main.async { + NotificationCenter.default.post(name: NSNotification.Name("downloadStatusChanged"), object: nil) + } + } + + private func downloadWithOriginalMethod(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) { + self.startDownload( + url: request.url, + headers: request.headers, + title: request.title, + imageURL: request.imageURL, + isEpisode: request.isEpisode, + showTitle: request.showTitle, + season: request.season, + episode: request.episode, + subtitleURL: request.subtitleURL, + showPosterURL: request.showPosterURL, + completionHandler: completionHandler + ) + } + + + private func setupMP4ProgressObservation(for task: AVAssetDownloadTask, downloadID: UUID) { + let observation = task.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] progress, _ in + DispatchQueue.main.async { + guard let self = self else { return } + self.updateMP4DownloadProgress(task: task, progress: progress.fractionCompleted) + NotificationCenter.default.post(name: NSNotification.Name("downloadProgressChanged"), object: nil) + } + } + + if mp4ProgressObservations == nil { + mp4ProgressObservations = [:] + } + mp4ProgressObservations?[downloadID] = observation + } + + private func updateMP4DownloadProgress(task: AVAssetDownloadTask, progress: Double) { + guard let downloadID = activeDownloadMap[task], + let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { + return + } + activeDownloads[downloadIndex].progress = progress + } + + func cleanupMP4ProgressObservation(for downloadID: UUID) { + mp4ProgressObservations?[downloadID]?.invalidate() + mp4ProgressObservations?[downloadID] = nil + } +} + + +extension JSController { + private func logDownloadStart(request: DownloadRequest) { + Logger.shared.log("Download process started for URL: \(request.url.absoluteString)", type: "Download") + Logger.shared.log("Title: \(request.title ?? "None"), Episode: \(request.isEpisode ? "Yes" : "No")", type: "Debug") + if let showTitle = request.showTitle, let episode = request.episode { + Logger.shared.log("Show: \(showTitle), Season: \(request.season ?? 1), Episode: \(episode)", type: "Debug") + } + if let subtitle = request.subtitleURL { + Logger.shared.log("Subtitle URL provided: \(subtitle.absoluteString)", type: "Debug") + } + } + + private func logM3U8Detection(preferredQuality: String) { + Logger.shared.log("M3U8 playlist detected - quality preference: \(preferredQuality)", type: "Download") + } + + private func logM3U8NoQualities() { + Logger.shared.log("No quality options found in M3U8, using original URL", type: "Warning") + } + + private func logM3U8QualitiesFound(qualities: [QualityOption]) { + Logger.shared.log("Found \(qualities.count) quality options in M3U8 playlist", type: "Download") + for (index, quality) in qualities.enumerated() { + Logger.shared.log("Quality \(index + 1): \(quality.name)", type: "Debug") + } + } + + private func logM3U8QualitySelected(quality: QualityOption) { + Logger.shared.log("Selected quality: \(quality.name)", type: "Download") + Logger.shared.log("Final download URL: \(quality.url)", type: "Debug") + } + + private func logM3U8InvalidURL() { + Logger.shared.log("Invalid quality URL detected, falling back to original", type: "Warning") + } + + private func logDirectDownload() { + Logger.shared.log("Direct download initiated (non-M3U8)", type: "Download") + } + + private func logMP4Detection() { + Logger.shared.log("MP4 stream detected, using MP4 download method", type: "Download") + } + + private func logM3U8FetchStart(url: URL) { + Logger.shared.log("Fetching M3U8 content from: \(url.absoluteString)", type: "Debug") + } + + private func logHTTPStatus(_ statusCode: Int, for url: URL) { + let logType = statusCode >= 400 ? "Error" : "Debug" + Logger.shared.log("HTTP \(statusCode) for M3U8 request: \(url.absoluteString)", type: logType) + } + + private func logM3U8FetchError(_ error: Error) { + Logger.shared.log("Failed to fetch M3U8 content: \(error.localizedDescription)", type: "Error") + } + + private func logM3U8DecodeError() { + Logger.shared.log("Failed to decode M3U8 file content", type: "Error") + } + + private func logM3U8FetchSuccess(dataSize: Int) { + Logger.shared.log("Successfully fetched M3U8 content (\(dataSize) bytes)", type: "Debug") + } + + private func logM3U8ParseStart(lineCount: Int) { + Logger.shared.log("Parsing M3U8 file with \(lineCount) lines", type: "Debug") + } + + private func logM3U8QualityAdded(quality: QualityOption) { + Logger.shared.log("Added quality option: \(quality.name)", type: "Debug") + } + + private func logM3U8ParseComplete(qualityCount: Int) { + Logger.shared.log("M3U8 parsing complete: \(qualityCount) quality options found", type: "Debug") + } + + private func logQualitySelectionSingle() { + Logger.shared.log("Only one quality available, using default", type: "Debug") + } + + private func logQualitySelectionStart(preference: String, sortedCount: Int) { + Logger.shared.log("Quality selection: \(sortedCount) options, preference: \(preference)", type: "Debug") + } + + private func logQualitySelectionResult(quality: QualityOption, preference: String) { + Logger.shared.log("Quality selected: \(quality.name) (preference: \(preference))", type: "Download") + } +} diff --git a/Sora/Utils/JSLoader/Downloads/JSController+M3U8Download.swift b/Sora/Utils/JSLoader/Downloads/JSController+M3U8Download.swift deleted file mode 100644 index 765253c..0000000 --- a/Sora/Utils/JSLoader/Downloads/JSController+M3U8Download.swift +++ /dev/null @@ -1,384 +0,0 @@ -// -// JSController+M3U8Download.swift -// Sora -// -// Created by doomsboygaming on 5/22/25 -// - -import Foundation -import SwiftUI - -// No need to import DownloadQualityPreference as it's in the same module - -// Extension for integrating M3U8StreamExtractor with JSController for downloads -extension JSController { - - /// Initiates a download for a given URL, handling M3U8 playlists if necessary - /// - Parameters: - /// - url: The URL to download - /// - headers: HTTP headers to use for the request - /// - title: Title for the download (optional) - /// - imageURL: Image URL for the content (optional) - /// - isEpisode: Whether this is an episode (defaults to false) - /// - showTitle: Title of the show this episode belongs to (optional) - /// - season: Season number (optional) - /// - episode: Episode number (optional) - /// - subtitleURL: Optional subtitle URL to download after video (optional) - /// - completionHandler: Called when the download is initiated or fails - func downloadWithM3U8Support(url: URL, headers: [String: String], title: String? = nil, - imageURL: URL? = nil, isEpisode: Bool = false, - showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, - subtitleURL: URL? = nil, showPosterURL: URL? = nil, - completionHandler: ((Bool, String) -> Void)? = nil) { - // Use headers passed in from caller rather than generating our own baseUrl - // Receiving code should already be setting module.metadata.baseUrl - - print("---- DOWNLOAD PROCESS STARTED ----") - print("Original URL: \(url.absoluteString)") - print("Headers: \(headers)") - print("Title: \(title ?? "None")") - print("Is Episode: \(isEpisode), Show: \(showTitle ?? "None"), Season: \(season?.description ?? "None"), Episode: \(episode?.description ?? "None")") - if let subtitle = subtitleURL { - print("Subtitle URL: \(subtitle.absoluteString)") - } - - // Check if the URL is an M3U8 file - if url.absoluteString.contains(".m3u8") { - // Get the user's quality preference - let preferredQuality = DownloadQualityPreference.current.rawValue - - print("URL detected as M3U8 playlist - will select quality based on user preference: \(preferredQuality)") - - // Parse the M3U8 content to extract available qualities, matching CustomPlayer approach - parseM3U8(url: url, baseUrl: url.absoluteString, headers: headers) { [weak self] qualities in - DispatchQueue.main.async { - guard let self = self else { return } - - if qualities.isEmpty { - print("M3U8 Analysis: No quality options found in M3U8, downloading with original URL") - self.downloadWithOriginalMethod( - url: url, - headers: headers, - title: title, - imageURL: imageURL, - isEpisode: isEpisode, - showTitle: showTitle, - season: season, - episode: episode, - subtitleURL: subtitleURL, - showPosterURL: showPosterURL, - completionHandler: completionHandler - ) - return - } - - print("M3U8 Analysis: Found \(qualities.count) quality options") - for (index, quality) in qualities.enumerated() { - print(" \(index + 1). \(quality.0) - \(quality.1)") - } - - // Select appropriate quality based on user preference - let selectedQuality = self.selectQualityBasedOnPreference(qualities: qualities, preferredQuality: preferredQuality) - - print("M3U8 Analysis: Selected quality: \(selectedQuality.0)") - print("M3U8 Analysis: Selected URL: \(selectedQuality.1)") - - if let qualityURL = URL(string: selectedQuality.1) { - print("FINAL DOWNLOAD URL: \(qualityURL.absoluteString)") - print("QUALITY SELECTED: \(selectedQuality.0)") - - // Download with standard headers that match the player - self.downloadWithOriginalMethod( - url: qualityURL, - headers: headers, - title: title, - imageURL: imageURL, - isEpisode: isEpisode, - showTitle: showTitle, - season: season, - episode: episode, - subtitleURL: subtitleURL, - showPosterURL: showPosterURL, - completionHandler: completionHandler - ) - } else { - print("M3U8 Analysis: Invalid quality URL, falling back to original URL") - print("FINAL DOWNLOAD URL (fallback): \(url.absoluteString)") - - self.downloadWithOriginalMethod( - url: url, - headers: headers, - title: title, - imageURL: imageURL, - isEpisode: isEpisode, - showTitle: showTitle, - season: season, - episode: episode, - subtitleURL: subtitleURL, - showPosterURL: showPosterURL, - completionHandler: completionHandler - ) - } - } - } - } else { - // Not an M3U8 file, use the original download method with standard headers - print("URL is not an M3U8 playlist - downloading directly") - print("FINAL DOWNLOAD URL (direct): \(url.absoluteString)") - - downloadWithOriginalMethod( - url: url, - headers: headers, - title: title, - imageURL: imageURL, - isEpisode: isEpisode, - showTitle: showTitle, - season: season, - episode: episode, - subtitleURL: subtitleURL, - showPosterURL: showPosterURL, - completionHandler: completionHandler - ) - } - } - - /// Parses an M3U8 file to extract available quality options, matching CustomPlayer's approach exactly - /// - Parameters: - /// - url: The URL of the M3U8 file - /// - baseUrl: The base URL for setting headers - /// - headers: HTTP headers to use for the request - /// - completion: Called with the array of quality options (name, URL) - private func parseM3U8(url: URL, baseUrl: String, headers: [String: String], completion: @escaping ([(String, String)]) -> Void) { - var request = URLRequest(url: url) - - // Add headers from headers passed to downloadWithM3U8Support - // This ensures we use the same headers as the player (from module.metadata.baseUrl) - for (key, value) in headers { - request.addValue(value, forHTTPHeaderField: key) - } - - print("M3U8 Parser: Fetching M3U8 content from: \(url.absoluteString)") - - URLSession.shared.dataTask(with: request) { data, response, error in - // Log HTTP status for debugging - if let httpResponse = response as? HTTPURLResponse { - print("M3U8 Parser: HTTP Status: \(httpResponse.statusCode) for \(url.absoluteString)") - - if httpResponse.statusCode >= 400 { - print("M3U8 Parser: HTTP Error: \(httpResponse.statusCode)") - completion([]) - return - } - } - - if let error = error { - print("M3U8 Parser: Error fetching M3U8: \(error.localizedDescription)") - completion([]) - return - } - - guard let data = data, let content = String(data: data, encoding: .utf8) else { - print("M3U8 Parser: Failed to load or decode M3U8 file") - completion([]) - return - } - - print("M3U8 Parser: Successfully fetched M3U8 content (\(data.count) bytes)") - - let lines = content.components(separatedBy: .newlines) - print("M3U8 Parser: Found \(lines.count) lines in M3U8 file") - - var qualities: [(String, String)] = [] - - // Always include the original URL as "Auto" option - qualities.append(("Auto (Recommended)", url.absoluteString)) - print("M3U8 Parser: Added 'Auto' quality option with original URL") - - 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" - } - } - - // Parse the M3U8 content to extract available streams - exactly like CustomPlayer - print("M3U8 Parser: Scanning for quality options...") - var qualitiesFound = 0 - - for (index, line) in lines.enumerated() { - if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count { - print("M3U8 Parser: Found stream info at line \(index): \(line)") - - 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.. (String, String) { - // If only one quality is available, return it - if qualities.count <= 1 { - print("Quality Selection: Only one quality option available, returning it directly") - return qualities[0] - } - - // Extract "Auto" quality and the remaining qualities - let autoQuality = qualities.first { $0.0.contains("Auto") } - let nonAutoQualities = qualities.filter { !$0.0.contains("Auto") } - - print("Quality Selection: Found \(nonAutoQualities.count) non-Auto quality options") - print("Quality Selection: Auto quality option: \(autoQuality?.0 ?? "None")") - - // Sort non-auto qualities by resolution (highest first) - let sortedQualities = nonAutoQualities.sorted { first, second in - let firstHeight = Int(first.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0 - let secondHeight = Int(second.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0 - return firstHeight > secondHeight - } - - print("Quality Selection: Sorted qualities (highest to lowest):") - for (index, quality) in sortedQualities.enumerated() { - print(" \(index + 1). \(quality.0) - \(quality.1)") - } - - print("Quality Selection: User preference is '\(preferredQuality)'") - - // Select quality based on preference - switch preferredQuality { - case "Best": - // Return the highest quality (first in sorted list) - let selected = sortedQualities.first ?? qualities[0] - print("Quality Selection: Selected 'Best' quality: \(selected.0)") - return selected - - case "High": - // Look for 720p quality - let highQuality = sortedQualities.first { - $0.0.contains("720p") || $0.0.contains("HD") - } - - if let high = highQuality { - print("Quality Selection: Found specific 'High' (720p/HD) quality: \(high.0)") - return high - } else if let first = sortedQualities.first { - print("Quality Selection: No specific 'High' quality found, using highest available: \(first.0)") - return first - } else { - print("Quality Selection: No non-Auto qualities found, falling back to default: \(qualities[0].0)") - return qualities[0] - } - - case "Medium": - // Look for 480p quality - let mediumQuality = sortedQualities.first { - $0.0.contains("480p") || $0.0.contains("SD") - } - - if let medium = mediumQuality { - print("Quality Selection: Found specific 'Medium' (480p/SD) quality: \(medium.0)") - return medium - } else if !sortedQualities.isEmpty { - // Return middle quality from sorted list if no exact match - let middleIndex = sortedQualities.count / 2 - print("Quality Selection: No specific 'Medium' quality found, using middle quality: \(sortedQualities[middleIndex].0)") - return sortedQualities[middleIndex] - } else { - print("Quality Selection: No non-Auto qualities found, falling back to default: \(autoQuality?.0 ?? qualities[0].0)") - return autoQuality ?? qualities[0] - } - - case "Low": - // Return lowest quality (last in sorted list) - if let lowest = sortedQualities.last { - print("Quality Selection: Selected 'Low' quality: \(lowest.0)") - return lowest - } else { - print("Quality Selection: No non-Auto qualities found, falling back to default: \(autoQuality?.0 ?? qualities[0].0)") - return autoQuality ?? qualities[0] - } - - default: - // Default to Auto if available, otherwise first quality - if let auto = autoQuality { - print("Quality Selection: Default case, using Auto quality: \(auto.0)") - return auto - } else { - print("Quality Selection: No Auto quality found, using first available: \(qualities[0].0)") - return qualities[0] - } - } - } - - /// The original download method (adapted to be called internally) - /// This method should match the existing download implementation in JSController-Downloads.swift - private func downloadWithOriginalMethod(url: URL, headers: [String: String], title: String? = nil, - imageURL: URL? = nil, isEpisode: Bool = false, - showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, - subtitleURL: URL? = nil, showPosterURL: URL? = nil, - completionHandler: ((Bool, String) -> Void)? = nil) { - // Call the existing download method - self.startDownload( - url: url, - headers: headers, - title: title, - imageURL: imageURL, - isEpisode: isEpisode, - showTitle: showTitle, - season: season, - episode: episode, - subtitleURL: subtitleURL, - showPosterURL: showPosterURL, - completionHandler: completionHandler - ) - } -} diff --git a/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift b/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift deleted file mode 100644 index 27e12e6..0000000 --- a/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift +++ /dev/null @@ -1,158 +0,0 @@ -// -// JSController+MP4Download.swift -// Sora -// -// Created by doomsboygaming on 5/22/25 -// - -import Foundation -import SwiftUI -import AVFoundation - -// Extension for handling MP4 direct video downloads using AVAssetDownloadTask -extension JSController { - - /// Initiates a download for a given MP4 URL using the existing AVAssetDownloadURLSession - /// - Parameters: - /// - url: The MP4 URL to download - /// - headers: HTTP headers to use for the request - /// - title: Title for the download (optional) - /// - imageURL: Image URL for the content (optional) - /// - isEpisode: Whether this is an episode (defaults to false) - /// - showTitle: Title of the show this episode belongs to (optional) - /// - season: Season number (optional) - /// - episode: Episode number (optional) - /// - subtitleURL: Optional subtitle URL to download after video (optional) - /// - completionHandler: Called when the download is initiated or fails - func downloadMP4(url: URL, headers: [String: String], title: String? = nil, - imageURL: URL? = nil, isEpisode: Bool = false, - showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, - subtitleURL: URL? = nil, showPosterURL: URL? = nil, - completionHandler: ((Bool, String) -> Void)? = nil) { - - // Validate URL - guard url.scheme == "http" || url.scheme == "https" else { - completionHandler?(false, "Invalid URL scheme") - return - } - - // Ensure download session is available - guard let downloadSession = downloadURLSession else { - completionHandler?(false, "Download session not available") - return - } - - // Create metadata for the download - var metadata: AssetMetadata? = nil - if let title = title { - metadata = AssetMetadata( - title: title, - posterURL: imageURL, - showTitle: showTitle, - season: season, - episode: episode, - showPosterURL: showPosterURL ?? imageURL - ) - } - - // Determine download type based on isEpisode - let downloadType: DownloadType = isEpisode ? .episode : .movie - - // Generate a unique download ID - let downloadID = UUID() - - // Create AVURLAsset with headers passed through AVURLAssetHTTPHeaderFieldsKey - let asset = AVURLAsset(url: url, options: [ - "AVURLAssetHTTPHeaderFieldsKey": headers - ]) - - // Create AVAssetDownloadTask using existing session - guard let downloadTask = downloadSession.makeAssetDownloadTask( - asset: asset, - assetTitle: title ?? url.lastPathComponent, - assetArtworkData: nil, - options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000] - ) else { - completionHandler?(false, "Failed to create download task") - return - } - - // Create an active download object - let activeDownload = JSActiveDownload( - id: downloadID, - originalURL: url, - progress: 0.0, - task: downloadTask, - urlSessionTask: nil, - queueStatus: .downloading, - type: downloadType, - metadata: metadata, - title: title, - imageURL: imageURL, - subtitleURL: subtitleURL, - asset: asset, - headers: headers, - module: nil - ) - - // Add to active downloads and tracking - activeDownloads.append(activeDownload) - activeDownloadMap[downloadTask] = downloadID - - // Set up progress observation for MP4 downloads - setupMP4ProgressObservation(for: downloadTask, downloadID: downloadID) - - // Start the download - downloadTask.resume() - - // Post notification for UI updates using NotificationCenter directly since postDownloadNotification is private - DispatchQueue.main.async { - NotificationCenter.default.post(name: NSNotification.Name("downloadStatusChanged"), object: nil) - } - - // Initial success callback - completionHandler?(true, "Download started") - } - - // MARK: - MP4 Progress Observation - - /// Sets up progress observation for MP4 downloads using AVAssetDownloadTask - /// Since AVAssetDownloadTask doesn't provide progress for single MP4 files through delegate methods, - /// we observe the task's progress property directly - private func setupMP4ProgressObservation(for task: AVAssetDownloadTask, downloadID: UUID) { - let observation = task.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] progress, _ in - DispatchQueue.main.async { - guard let self = self else { return } - - // Update download progress using existing infrastructure - self.updateMP4DownloadProgress(task: task, progress: progress.fractionCompleted) - - // Post notification for UI updates - NotificationCenter.default.post(name: NSNotification.Name("downloadProgressChanged"), object: nil) - } - } - - // Store observation for cleanup using existing property from main JSController class - if mp4ProgressObservations == nil { - mp4ProgressObservations = [:] - } - mp4ProgressObservations?[downloadID] = observation - } - - /// Updates download progress for a specific MP4 task (avoiding name collision with existing method) - private func updateMP4DownloadProgress(task: AVAssetDownloadTask, progress: Double) { - guard let downloadID = activeDownloadMap[task], - let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { - return - } - - // Update progress using existing mechanism - activeDownloads[downloadIndex].progress = progress - } - - /// Cleans up MP4 progress observation for a specific download - func cleanupMP4ProgressObservation(for downloadID: UUID) { - mp4ProgressObservations?[downloadID]?.invalidate() - mp4ProgressObservations?[downloadID] = nil - } -} diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 17f705a..dea242c 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -93,12 +93,11 @@ 7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */; }; 722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */; }; 722248632DCBAA4700CABE2D /* JSController-HeaderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */; }; - 722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */; }; 722248662DCBC13E00CABE2D /* JSController-Downloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248652DCBC13E00CABE2D /* JSController-Downloads.swift */; }; 72443C7D2DC8036500A61321 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7C2DC8036500A61321 /* DownloadView.swift */; }; 72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */; }; 727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */; }; - 727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */; }; + 72D546DB2DFC5E950044C567 /* JSController+Downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */; }; 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; }; /* End PBXBuildFile section */ @@ -187,12 +186,11 @@ 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModels.swift; sourceTree = ""; }; 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = M3U8StreamExtractor.swift; sourceTree = ""; }; 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-HeaderManager.swift"; sourceTree = ""; }; - 722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+M3U8Download.swift"; sourceTree = ""; }; 722248652DCBC13E00CABE2D /* JSController-Downloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Downloads.swift"; sourceTree = ""; }; 72443C7C2DC8036500A61321 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; }; 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewDownloads.swift; sourceTree = ""; }; 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-StreamTypeDownload.swift"; sourceTree = ""; }; - 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+MP4Download.swift"; sourceTree = ""; }; + 72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+Downloader.swift"; sourceTree = ""; }; 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -470,11 +468,10 @@ 134A387B2DE4B5B90041B687 /* Downloads */ = { isa = PBXGroup; children = ( + 72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */, 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */, - 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */, 722248652DCBC13E00CABE2D /* JSController-Downloads.swift */, 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */, - 722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */, ); path = Downloads; sourceTree = ""; @@ -757,7 +754,6 @@ 13DB468E2D90093A008CBC03 /* Anilist-Token.swift in Sources */, 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */, 722248632DCBAA4700CABE2D /* JSController-HeaderManager.swift in Sources */, - 722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */, 133D7C942D2BE2640075467E /* JSController.swift in Sources */, 133D7C922D2BE2640075467E /* URLSession.swift in Sources */, 0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */, @@ -776,9 +772,9 @@ 13B77E202DA457AA00126FDF /* AniListPushUpdates.swift in Sources */, 13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */, 727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */, - 727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */, 0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */, 0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */, + 72D546DB2DFC5E950044C567 /* JSController+Downloader.swift in Sources */, 0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */, 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */, 04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */,