diff --git a/Sora/Utils/DownloadUtils/DownloadManager.swift b/Sora/Utils/DownloadUtils/DownloadManager.swift index 9c3d90d..454f6a5 100644 --- a/Sora/Utils/DownloadUtils/DownloadManager.swift +++ b/Sora/Utils/DownloadUtils/DownloadManager.swift @@ -54,7 +54,7 @@ class DownloadManager: NSObject, ObservableObject { options: .skipsHiddenFiles ) - if let localURL = contents.first(where: { $0.pathExtension == "movpkg" }) { + if let localURL = contents.first(where: { ["movpkg", "mp4"].contains($0.pathExtension.lowercased()) }) { localPlaybackURL = localURL } } catch { diff --git a/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift b/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift index 4f8090c..27e12e6 100644 --- a/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift +++ b/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift @@ -7,11 +7,12 @@ import Foundation import SwiftUI +import AVFoundation -// Extension for handling MP4 direct video downloads +// Extension for handling MP4 direct video downloads using AVAssetDownloadTask extension JSController { - /// Initiates a download for a given MP4 URL + /// 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 @@ -26,24 +27,21 @@ extension JSController { 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, + subtitleURL: URL? = nil, showPosterURL: URL? = nil, completionHandler: ((Bool, String) -> Void)? = nil) { - print("---- MP4 DOWNLOAD PROCESS STARTED ----") - print("MP4 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)") - } - // 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 { @@ -53,7 +51,7 @@ extension JSController { showTitle: showTitle, season: season, episode: episode, - showPosterURL: imageURL + showPosterURL: showPosterURL ?? imageURL ) } @@ -63,233 +61,98 @@ extension JSController { // Generate a unique download ID let downloadID = UUID() - // Get access to the download directory - guard let downloadDirectory = getPersistentDownloadDirectory() else { - print("MP4 Download: Failed to get download directory") - completionHandler?(false, "Failed to create download directory") + // 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 } - // Generate a safe filename for the MP4 file - let sanitizedTitle = title?.replacingOccurrences(of: "[^A-Za-z0-9 ._-]", with: "", options: .regularExpression) ?? "download" - let filename = "\(sanitizedTitle)_\(downloadID.uuidString.prefix(8)).mp4" - let destinationURL = downloadDirectory.appendingPathComponent(filename) - // Create an active download object let activeDownload = JSActiveDownload( id: downloadID, originalURL: url, - task: nil, + progress: 0.0, + task: downloadTask, + urlSessionTask: nil, queueStatus: .downloading, type: downloadType, metadata: metadata, title: title, imageURL: imageURL, subtitleURL: subtitleURL, - headers: headers + asset: asset, + headers: headers, + module: nil ) - // Add to active downloads + // Add to active downloads and tracking activeDownloads.append(activeDownload) + activeDownloadMap[downloadTask] = downloadID - // Create request with headers - var request = URLRequest(url: url) - request.timeoutInterval = 30.0 - for (key, value) in headers { - request.addValue(value, forHTTPHeaderField: key) - } + // Set up progress observation for MP4 downloads + setupMP4ProgressObservation(for: downloadTask, downloadID: downloadID) - let sessionConfig = URLSessionConfiguration.default - sessionConfig.timeoutIntervalForRequest = 60.0 - sessionConfig.timeoutIntervalForResource = 1800.0 - sessionConfig.httpMaximumConnectionsPerHost = 1 - sessionConfig.allowsCellularAccess = true - - // Create custom session with delegate (self is JSController, which is persistent) - let customSession = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil) - - // Create the download task - let downloadTask = customSession.downloadTask(with: request) { [weak self] (tempURL, response, error) in - guard let self = self else { return } - - DispatchQueue.main.async { - defer { - // Clean up resources - self.cleanupDownloadResources(for: downloadID) - } - - // Handle error cases - just remove from active downloads - if let error = error { - print("MP4 Download Error: \(error.localizedDescription)") - self.removeActiveDownload(downloadID: downloadID) - completionHandler?(false, "Download failed: \(error.localizedDescription)") - return - } - - // Validate response - guard let httpResponse = response as? HTTPURLResponse else { - print("MP4 Download: Invalid response") - self.removeActiveDownload(downloadID: downloadID) - completionHandler?(false, "Invalid server response") - return - } - - guard (200...299).contains(httpResponse.statusCode) else { - print("MP4 Download HTTP Error: \(httpResponse.statusCode)") - self.removeActiveDownload(downloadID: downloadID) - completionHandler?(false, "Server error: \(httpResponse.statusCode)") - return - } - - guard let tempURL = tempURL else { - print("MP4 Download: No temporary file URL") - self.removeActiveDownload(downloadID: downloadID) - completionHandler?(false, "Download data not available") - return - } - - // Move file to final destination - do { - if FileManager.default.fileExists(atPath: destinationURL.path) { - try FileManager.default.removeItem(at: destinationURL) - } - - try FileManager.default.moveItem(at: tempURL, to: destinationURL) - print("MP4 Download: Successfully saved to \(destinationURL.path)") - - // Verify file size - let fileSize = try FileManager.default.attributesOfItem(atPath: destinationURL.path)[.size] as? Int64 ?? 0 - guard fileSize > 0 else { - throw NSError(domain: "DownloadError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Downloaded file is empty"]) - } - - // Create downloaded asset - let downloadedAsset = DownloadedAsset( - name: title ?? url.lastPathComponent, - downloadDate: Date(), - originalURL: url, - localURL: destinationURL, - type: downloadType, - metadata: metadata, - subtitleURL: subtitleURL - ) - - // Save asset - self.savedAssets.append(downloadedAsset) - self.saveAssets() - - // Update progress to complete and remove after delay - self.updateDownloadProgress(downloadID: downloadID, progress: 1.0) - - // Download subtitle if provided - if let subtitleURL = subtitleURL { - self.downloadSubtitle(subtitleURL: subtitleURL, assetID: downloadedAsset.id.uuidString) - } - - // Notify completion - NotificationCenter.default.post(name: NSNotification.Name("downloadCompleted"), object: downloadedAsset) - completionHandler?(true, "Download completed successfully") - - // Remove from active downloads after success - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.removeActiveDownload(downloadID: downloadID) - } - - } catch { - print("MP4 Download Error saving file: \(error.localizedDescription)") - self.removeActiveDownload(downloadID: downloadID) - completionHandler?(false, "Error saving download: \(error.localizedDescription)") - } - } - } - - // Set up progress observation - setupProgressObservation(for: downloadTask, downloadID: downloadID) - - // Store session reference - storeSessionReference(session: customSession, for: downloadID) - - // Start download + // Start the download downloadTask.resume() - print("MP4 Download: Task started for \(filename)") + + // 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: - Helper Methods + // MARK: - MP4 Progress Observation - private func removeActiveDownload(downloadID: UUID) { - activeDownloads.removeAll { $0.id == downloadID } - } - - private func updateDownloadProgress(downloadID: UUID, progress: Double) { - guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { return } - activeDownloads[index].progress = progress - } - - private func setupProgressObservation(for task: URLSessionDownloadTask, downloadID: UUID) { - let observation = task.progress.observe(\.fractionCompleted) { [weak self] progress, _ in + /// 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 } - self.updateDownloadProgress(downloadID: downloadID, progress: progress.fractionCompleted) + + // 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 } - private func storeSessionReference(session: URLSession, for downloadID: UUID) { - if mp4CustomSessions == nil { - mp4CustomSessions = [:] - } - mp4CustomSessions?[downloadID] = session - } - - private func cleanupDownloadResources(for downloadID: UUID) { - mp4ProgressObservations?[downloadID] = nil - mp4CustomSessions?[downloadID] = nil - } -} - -// MARK: - URLSessionDelegate -extension JSController: URLSessionDelegate { - func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - - guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust else { - completionHandler(.performDefaultHandling, nil) + /// 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 } - let host = challenge.protectionSpace.host - print("MP4 Download: Handling server trust challenge for host: \(host)") - - // Define trusted hosts for MP4 downloads - let trustedHosts = [ - "streamtales.cc", - "frembed.xyz", - "vidclouds.cc" - ] - - let isTrustedHost = trustedHosts.contains { host.contains($0) } - let isCustomSession = mp4CustomSessions?.values.contains(session) == true - - if isTrustedHost || isCustomSession { - guard let serverTrust = challenge.protectionSpace.serverTrust else { - completionHandler(.performDefaultHandling, nil) - return - } - - print("MP4 Download: Accepting certificate for \(host)") - let credential = URLCredential(trust: serverTrust) - completionHandler(.useCredential, credential) - } else { - print("MP4 Download: Using default handling for \(host)") - completionHandler(.performDefaultHandling, nil) - } + // 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/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift b/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift index a6e9f6b..be358fe 100644 --- a/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift +++ b/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift @@ -162,6 +162,7 @@ extension JSController { originalURL: url, progress: 0, task: nil, // Task will be created when the download starts + urlSessionTask: nil, queueStatus: .queued, type: downloadType, metadata: assetMetadata, @@ -299,6 +300,7 @@ extension JSController { originalURL: queuedDownload.originalURL, progress: 0, task: task, + urlSessionTask: nil, queueStatus: .downloading, type: queuedDownload.type, metadata: queuedDownload.metadata, @@ -364,6 +366,11 @@ extension JSController { private func cleanupDownloadTask(_ task: URLSessionTask) { guard let downloadID = activeDownloadMap[task] else { return } + // Clean up MP4 progress observations if this is an MP4 download + if task is AVAssetDownloadTask { + cleanupMP4ProgressObservation(for: downloadID) + } + activeDownloads.removeAll { $0.id == downloadID } activeDownloadMap.removeValue(forKey: task) @@ -648,15 +655,15 @@ extension JSController { print("Created persistent download directory at \(persistentDir.path)") } - // Find any .movpkg files in the Documents directory + // Find any video files (.movpkg, .mp4) in the Documents directory let files = try fileManager.contentsOfDirectory(at: documentsDir, includingPropertiesForKeys: nil) - let movpkgFiles = files.filter { $0.pathExtension == "movpkg" } + let videoFiles = files.filter { ["movpkg", "mp4"].contains($0.pathExtension.lowercased()) } - if !movpkgFiles.isEmpty { - print("Found \(movpkgFiles.count) .movpkg files in Documents directory to migrate") + if !videoFiles.isEmpty { + print("Found \(videoFiles.count) video files in Documents directory to migrate") // Migrate each file - for fileURL in movpkgFiles { + for fileURL in videoFiles { let filename = fileURL.lastPathComponent let destinationURL = persistentDir.appendingPathComponent(filename) @@ -674,7 +681,7 @@ extension JSController { } } } else { - print("No .movpkg files found in Documents directory for migration") + print("No video files found in Documents directory for migration") } } catch { print("Error during migration: \(error.localizedDescription)") @@ -808,8 +815,8 @@ extension JSController { // Get all files in the directory let files = try fileManager.contentsOfDirectory(at: downloadDir, includingPropertiesForKeys: nil) - // Try to find a file that contains the asset name - for file in files where file.pathExtension == "movpkg" { + // Try to find a video file that contains the asset name + for file in files where ["movpkg", "mp4"].contains(file.pathExtension.lowercased()) { let filename = file.lastPathComponent // If the filename contains the asset name, it's likely our file @@ -1088,21 +1095,14 @@ extension JSController { return .notDownloaded } - /// Cancel a queued download that hasn't started yet + /// Cancel a queued download func cancelQueuedDownload(_ downloadID: UUID) { - // Remove from the download queue if it exists there - if let index = downloadQueue.firstIndex(where: { $0.id == downloadID }) { - let downloadTitle = downloadQueue[index].title ?? downloadQueue[index].originalURL.lastPathComponent - downloadQueue.remove(at: index) - - // Show notification - DropManager.shared.info("Download cancelled: \(downloadTitle)") - - // Notify observers of status change (no cache clearing needed for cancellation) - postDownloadNotification(.statusChange) - - print("Cancelled queued download: \(downloadTitle)") - } + downloadQueue.removeAll { $0.id == downloadID } + + // Notify of the cancellation + postDownloadNotification(.statusChange) + + print("Cancelled queued download: \(downloadID)") } /// Cancel an active download that is currently in progress @@ -1111,12 +1111,16 @@ extension JSController { cancelledDownloadIDs.insert(downloadID) // Find the active download and cancel its task - if let activeDownload = activeDownloads.first(where: { $0.id == downloadID }), - let task = activeDownload.task { + if let activeDownload = activeDownloads.first(where: { $0.id == downloadID }) { let downloadTitle = activeDownload.title ?? activeDownload.originalURL.lastPathComponent - // Cancel the actual download task - task.cancel() + if let task = activeDownload.task { + // M3U8 download - cancel AVAssetDownloadTask + task.cancel() + } else if let urlTask = activeDownload.urlSessionTask { + // MP4 download - cancel URLSessionDownloadTask + urlTask.cancel() + } // Show notification DropManager.shared.info("Download cancelled: \(downloadTitle)") @@ -1124,6 +1128,46 @@ extension JSController { print("Cancelled active download: \(downloadTitle)") } } + + /// Pause an MP4 download + func pauseMP4Download(_ downloadID: UUID) { + guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { + print("MP4 Download not found for pausing: \(downloadID)") + return + } + + let download = activeDownloads[index] + guard let urlTask = download.urlSessionTask else { + print("No URL session task found for MP4 download: \(downloadID)") + return + } + + urlTask.suspend() + print("Paused MP4 download: \(download.title ?? download.originalURL.lastPathComponent)") + + // Notify UI of status change + postDownloadNotification(.statusChange) + } + + /// Resume an MP4 download + func resumeMP4Download(_ downloadID: UUID) { + guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { + print("MP4 Download not found for resuming: \(downloadID)") + return + } + + let download = activeDownloads[index] + guard let urlTask = download.urlSessionTask else { + print("No URL session task found for MP4 download: \(downloadID)") + return + } + + urlTask.resume() + print("Resumed MP4 download: \(download.title ?? download.originalURL.lastPathComponent)") + + // Notify UI of status change + postDownloadNotification(.statusChange) + } } // MARK: - AVAssetDownloadDelegate @@ -1220,7 +1264,28 @@ extension JSController: AVAssetDownloadDelegate { let safeFilename = filename.replacingOccurrences(of: "/", with: "-") .replacingOccurrences(of: ":", with: "-") - let destinationURL = downloadDir.appendingPathComponent("\(safeFilename)-\(uniqueID).movpkg") + // Determine file extension based on the source location + let fileExtension: String + if location.pathExtension.isEmpty { + // If no extension from the source, check if it's likely an HLS download (which becomes .movpkg) + // or preserve original URL extension + if safeFilename.contains(".m3u8") || safeFilename.contains("hls") { + fileExtension = "movpkg" + print("Using .movpkg extension for HLS download: \(safeFilename)") + } else { + fileExtension = "mp4" // Default for direct video downloads + print("Using .mp4 extension for direct video download: \(safeFilename)") + } + } else { + // Use the extension from the downloaded file + let sourceExtension = location.pathExtension.lowercased() + fileExtension = (sourceExtension == "movpkg") ? "movpkg" : "mp4" + print("Using extension from source file: \(sourceExtension) -> \(fileExtension)") + } + + print("Final destination will be: \(safeFilename)-\(uniqueID).\(fileExtension)") + + let destinationURL = downloadDir.appendingPathComponent("\(safeFilename)-\(uniqueID).\(fileExtension)") // Move the file to the persistent location try fileManager.moveItem(at: location, to: destinationURL) @@ -1458,6 +1523,7 @@ struct JSActiveDownload: Identifiable, Equatable { let originalURL: URL var progress: Double let task: AVAssetDownloadTask? + let urlSessionTask: URLSessionDownloadTask? let type: DownloadType var metadata: AssetMetadata? var title: String? @@ -1468,6 +1534,22 @@ struct JSActiveDownload: Identifiable, Equatable { var headers: [String: String] var module: ScrapingModule? // Add module property to store ScrapingModule + // Computed property to get the current task state + var taskState: URLSessionTask.State { + if let avTask = task { + return avTask.state + } else if let urlTask = urlSessionTask { + return urlTask.state + } else { + return .suspended + } + } + + // Computed property to get the underlying task for control operations + var underlyingTask: URLSessionTask? { + return task ?? urlSessionTask + } + // Implement Equatable static func == (lhs: JSActiveDownload, rhs: JSActiveDownload) -> Bool { return lhs.id == rhs.id && @@ -1485,6 +1567,7 @@ struct JSActiveDownload: Identifiable, Equatable { originalURL: URL, progress: Double = 0, task: AVAssetDownloadTask? = nil, + urlSessionTask: URLSessionDownloadTask? = nil, queueStatus: DownloadQueueStatus = .queued, type: DownloadType = .movie, metadata: AssetMetadata? = nil, @@ -1499,6 +1582,7 @@ struct JSActiveDownload: Identifiable, Equatable { self.originalURL = originalURL self.progress = progress self.task = task + self.urlSessionTask = urlSessionTask self.type = type self.metadata = metadata self.title = title diff --git a/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift b/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift index 5ae5da0..5f8f741 100644 --- a/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift +++ b/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift @@ -70,12 +70,13 @@ extension JSController { url: url, headers: headers, title: title, - imageURL: imageURL ?? showPosterURL, + imageURL: imageURL, isEpisode: isEpisode, showTitle: showTitle, season: season, episode: episode, subtitleURL: subtitleURL, + showPosterURL: showPosterURL, completionHandler: completionHandler ) } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 5532b70..9483e0e 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -225,22 +225,47 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele fatalError("Invalid URL string") } - var request = URLRequest(url: url) - if let mydict = headers, !mydict.isEmpty { - for (key,value) in mydict { - request.addValue(value, forHTTPHeaderField: key) + let asset: AVURLAsset + + // Check if this is a local file URL + if url.scheme == "file" { + // For local files, don't add HTTP headers + Logger.shared.log("Loading local file: \(url.absoluteString)", type: "Debug") + + // Check if file exists + if FileManager.default.fileExists(atPath: url.path) { + Logger.shared.log("Local file exists at path: \(url.path)", type: "Debug") + } else { + Logger.shared.log("WARNING: Local file does not exist at path: \(url.path)", type: "Error") } + + asset = AVURLAsset(url: url) } else { - request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") - request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin") + // For remote URLs, add HTTP headers + Logger.shared.log("Loading remote URL: \(url.absoluteString)", type: "Debug") + var request = URLRequest(url: url) + if let mydict = headers, !mydict.isEmpty { + for (key,value) in mydict { + request.addValue(value, forHTTPHeaderField: key) + } + } else { + request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") + request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin") + } + + request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") + + asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) } - request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") - - let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) let playerItem = AVPlayerItem(asset: asset) self.player = AVPlayer(playerItem: playerItem) + // Add error observation + playerItem.addObserver(self, forKeyPath: "status", options: [.new], context: &playerItemKVOContext) + + Logger.shared.log("Created AVPlayerItem with status: \(playerItem.status.rawValue)", type: "Debug") + let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)") if lastPlayedTime > 0 { let seekTime = CMTime(seconds: lastPlayedTime, preferredTimescale: 1) @@ -446,6 +471,11 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele timeObserverToken = nil } + // Remove observer from player item if it exists + if let currentItem = player?.currentItem { + currentItem.removeObserver(self, forKeyPath: "status", context: &playerItemKVOContext) + } + player?.replaceCurrentItem(with: nil) player?.pause() player = nil @@ -495,7 +525,28 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele return } - if keyPath == "loadedTimeRanges" { + if keyPath == "status" { + if let playerItem = object as? AVPlayerItem { + switch playerItem.status { + case .readyToPlay: + Logger.shared.log("AVPlayerItem status: Ready to play", type: "Debug") + case .failed: + if let error = playerItem.error { + Logger.shared.log("AVPlayerItem failed with error: \(error.localizedDescription)", type: "Error") + if let nsError = error as NSError? { + Logger.shared.log("Error domain: \(nsError.domain), code: \(nsError.code), userInfo: \(nsError.userInfo)", type: "Error") + } + } else { + Logger.shared.log("AVPlayerItem failed with unknown error", type: "Error") + } + case .unknown: + Logger.shared.log("AVPlayerItem status: Unknown", type: "Debug") + @unknown default: + Logger.shared.log("AVPlayerItem status: Unknown default case", type: "Debug") + } + } + } else if keyPath == "loadedTimeRanges" { + // Handle loaded time ranges if needed } } @@ -2034,6 +2085,15 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private func parseM3U8(url: URL, completion: @escaping () -> Void) { + // For local file URLs, use a simple data task without custom headers + if url.scheme == "file" { + URLSession.shared.dataTask(with: url) { [weak self] data, response, error in + self?.processM3U8Data(data: data, url: url, completion: completion) + }.resume() + return + } + + // For remote URLs, add HTTP headers var request = URLRequest(url: url) if let mydict = headers, !mydict.isEmpty { for (key,value) in mydict { @@ -2046,77 +2106,80 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") URLSession.shared.dataTask(with: request) { [weak self] data, response, error in - guard let self = self, - let data = data, - let content = String(data: data, encoding: .utf8) else { - Logger.shared.log("Failed to load m3u8 file") - DispatchQueue.main.async { - self?.qualities = [] - completion() - } - return + self?.processM3U8Data(data: data, url: url, completion: completion) + }.resume() + } + + private func processM3U8Data(data: Data?, url: URL, completion: @escaping () -> Void) { + guard let data = data, + let content = String(data: data, encoding: .utf8) else { + Logger.shared.log("Failed to load m3u8 file") + DispatchQueue.main.async { + self.qualities = [] + completion() } - - let lines = content.components(separatedBy: .newlines) - var qualities: [(String, String)] = [] - - qualities.append(("Auto (Recommended)", url.absoluteString)) - - 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" - } + return + } + + let lines = content.components(separatedBy: .newlines) + var qualities: [(String, String)] = [] + + qualities.append(("Auto (Recommended)", url.absoluteString)) + + 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" } - - for (index, line) in lines.enumerated() { - if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count { - if let resolutionRange = line.range(of: "RESOLUTION="), - let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",") - ?? line[resolutionRange.upperBound...].range(of: "\n") { + } + + for (index, line) in lines.enumerated() { + if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count { + 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.. secondHeight - } - - if let auto = autoQuality { - sortedQualities.insert(auto, at: 0) - } - - self.qualities = sortedQualities - completion() + } + + DispatchQueue.main.async { + let autoQuality = qualities.first + var sortedQualities = qualities.dropFirst().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 } - }.resume() + + if let auto = autoQuality { + sortedQualities.insert(auto, at: 0) + } + + self.qualities = sortedQualities + completion() + } } private func switchToQuality(urlString: String) { @@ -2126,20 +2189,48 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let currentTime = player.currentTime() let wasPlaying = player.rate > 0 - var request = URLRequest(url: url) - if let mydict = headers, !mydict.isEmpty { - for (key,value) in mydict { - request.addValue(value, forHTTPHeaderField: key) - } - } else { - request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") - request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin") - } - request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") + let asset: AVURLAsset + + // Check if this is a local file URL + if url.scheme == "file" { + // For local files, don't add HTTP headers + Logger.shared.log("Switching to local file: \(url.absoluteString)", type: "Debug") + + // Check if file exists + if FileManager.default.fileExists(atPath: url.path) { + Logger.shared.log("Local file exists for quality switch: \(url.path)", type: "Debug") + } else { + Logger.shared.log("WARNING: Local file does not exist for quality switch: \(url.path)", type: "Error") + } + + asset = AVURLAsset(url: url) + } else { + // For remote URLs, add HTTP headers + Logger.shared.log("Switching to remote URL: \(url.absoluteString)", type: "Debug") + var request = URLRequest(url: url) + if let mydict = headers, !mydict.isEmpty { + for (key,value) in mydict { + request.addValue(value, forHTTPHeaderField: key) + } + } else { + request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") + request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin") + } + request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") + + asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) + } - let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) let playerItem = AVPlayerItem(asset: asset) + // Add observer for the new player item + playerItem.addObserver(self, forKeyPath: "status", options: [.new], context: &playerItemKVOContext) + + // Remove observer from old item if it exists + if let currentItem = player.currentItem { + currentItem.removeObserver(self, forKeyPath: "status", context: &playerItemKVOContext) + } + player.replaceCurrentItem(with: playerItem) player.seek(to: currentTime) if wasPlaying { diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index f8fe944..6871a74 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -240,39 +240,26 @@ struct DownloadView: View { metadataUrl: "" ) - if streamType == "mp4" { - let playerItem = AVPlayerItem(url: asset.localURL) - let player = AVPlayer(playerItem: playerItem) - let playerController = AVPlayerViewController() - playerController.player = player - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootViewController = windowScene.windows.first?.rootViewController { - rootViewController.present(playerController, animated: true) { - player.play() - } - } - } else { - let customPlayer = CustomMediaPlayerViewController( - module: dummyModule, - urlString: asset.localURL.absoluteString, - fullUrl: asset.originalURL.absoluteString, - title: asset.metadata?.showTitle ?? asset.name, - episodeNumber: asset.metadata?.episode ?? 0, - onWatchNext: {}, - subtitlesURL: asset.localSubtitleURL?.absoluteString, - aniListID: 0, - totalEpisodes: asset.metadata?.episode ?? 0, - episodeImageUrl: asset.metadata?.posterURL?.absoluteString ?? "", - headers: nil - ) - - customPlayer.modalPresentationStyle = UIModalPresentationStyle.fullScreen - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootViewController = windowScene.windows.first?.rootViewController { - rootViewController.present(customPlayer, animated: true) - } + // Always use CustomMediaPlayerViewController for consistency + let customPlayer = CustomMediaPlayerViewController( + module: dummyModule, + urlString: asset.localURL.absoluteString, + fullUrl: asset.originalURL.absoluteString, + title: asset.metadata?.showTitle ?? asset.name, + episodeNumber: asset.metadata?.episode ?? 0, + onWatchNext: {}, + subtitlesURL: asset.localSubtitleURL?.absoluteString, + aniListID: 0, + totalEpisodes: asset.metadata?.episode ?? 0, + episodeImageUrl: asset.metadata?.posterURL?.absoluteString ?? "", + headers: nil + ) + + customPlayer.modalPresentationStyle = UIModalPresentationStyle.fullScreen + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController { + rootViewController.present(customPlayer, animated: true) } } } @@ -733,7 +720,7 @@ struct EnhancedActiveDownloadCard: View { init(download: JSActiveDownload) { self.download = download _currentProgress = State(initialValue: download.progress) - _taskState = State(initialValue: download.task?.state ?? .suspended) + _taskState = State(initialValue: download.taskState) } var body: some View { @@ -868,18 +855,30 @@ struct EnhancedActiveDownloadCard: View { withAnimation(.easeInOut(duration: 0.1)) { currentProgress = currentDownload.progress } - if let task = currentDownload.task { - taskState = task.state - } + taskState = currentDownload.taskState } } private func toggleDownload() { if taskState == .running { - download.task?.suspend() + // Pause the download + if download.task != nil { + // M3U8 download - use AVAssetDownloadTask + download.underlyingTask?.suspend() + } else if download.urlSessionTask != nil { + // MP4 download - use dedicated method + JSController.shared.pauseMP4Download(download.id) + } taskState = .suspended } else if taskState == .suspended { - download.task?.resume() + // Resume the download + if download.task != nil { + // M3U8 download - use AVAssetDownloadTask + download.underlyingTask?.resume() + } else if download.urlSessionTask != nil { + // MP4 download - use dedicated method + JSController.shared.resumeMP4Download(download.id) + } taskState = .running } }