From e14b00fc1390e512fa72574ba0133bd6b35d6bfc Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Sat, 31 May 2025 22:00:03 +0200 Subject: [PATCH] test --- .../Downloads/JSController+MP4Download.swift | 295 +++++++++++------- .../Downloads/JSController-Downloads.swift | 27 +- Sora/Views/DownloadView.swift | 54 +++- 3 files changed, 252 insertions(+), 124 deletions(-) diff --git a/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift b/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift index dacd549..7c2a960 100644 --- a/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift +++ b/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift @@ -75,11 +75,11 @@ extension JSController { let filename = "\(sanitizedTitle)_\(downloadID.uuidString.prefix(8)).mp4" let destinationURL = downloadDirectory.appendingPathComponent(filename) - // Create an active download object - let activeDownload = JSActiveDownload( + // Create an active download object with proper initial status + var activeDownload = JSActiveDownload( id: downloadID, originalURL: url, - task: nil, + task: nil, // Will be set after task creation queueStatus: .downloading, type: downloadType, metadata: metadata, @@ -89,123 +89,27 @@ extension JSController { headers: headers ) - // Add to active downloads - activeDownloads.append(activeDownload) - - // Create request with headers - var request = URLRequest(url: url) - request.timeoutInterval = 30.0 - for (key, value) in headers { - request.addValue(value, forHTTPHeaderField: key) - } - - // Enhanced session configuration - let sessionConfig = URLSessionConfiguration.default + // Enhanced session configuration for background downloads + let sessionConfig = URLSessionConfiguration.background(withIdentifier: "mp4-download-\(downloadID.uuidString)") sessionConfig.timeoutIntervalForRequest = 60.0 - sessionConfig.timeoutIntervalForResource = 1800.0 + sessionConfig.timeoutIntervalForResource = 3600.0 // 1 hour for large files sessionConfig.httpMaximumConnectionsPerHost = 1 sessionConfig.allowsCellularAccess = true + sessionConfig.shouldUseExtendedBackgroundIdleMode = true + sessionConfig.waitsForConnectivity = true // Create custom session with delegate - let customSession = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil) + let customSession = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: .main) // 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)") - } - } - } + let downloadTask = customSession.downloadTask(with: request) - // Set up progress observation - setupProgressObservation(for: downloadTask, downloadID: downloadID) + // Update active download with the task + activeDownload.task = downloadTask + + // Add to active downloads and create task mapping + activeDownloads.append(activeDownload) + activeDownloadMap[downloadTask] = downloadID // Store session reference storeSessionReference(session: customSession, for: downloadID) @@ -214,6 +118,18 @@ extension JSController { downloadTask.resume() print("MP4 Download: Task started for \(filename)") + // Post initial status notification + postDownloadNotification(.statusChange) + + // If this is an episode, post initial progress update + if let episodeNumber = metadata?.episode { + postDownloadNotification(.progress, userInfo: [ + "episodeNumber": episodeNumber, + "progress": 0.0, + "status": "downloading" + ]) + } + // Initial success callback completionHandler?(true, "Download started") } @@ -221,12 +137,47 @@ extension JSController { // MARK: - Helper Methods private func removeActiveDownload(downloadID: UUID) { - activeDownloads.removeAll { $0.id == downloadID } + // Find and remove the download + if let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) { + let download = activeDownloads[index] + activeDownloads.remove(at: index) + + // Clean up task mapping + if let task = download.task { + activeDownloadMap.removeValue(forKey: task) + } + + // Clean up resources + cleanupDownloadResources(for: downloadID) + + // Post status change notification + postDownloadNotification(.statusChange) + } } private func updateDownloadProgress(downloadID: UUID, progress: Double) { guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { return } - activeDownloads[index].progress = progress + + let previousProgress = activeDownloads[index].progress + activeDownloads[index].progress = min(max(progress, 0.0), 1.0) + + // Only post notifications for meaningful progress changes (every 1% or completion) + let progressDifference = progress - previousProgress + if progressDifference >= 0.01 || progress >= 1.0 || previousProgress == 0.0 { + // Post general progress notification + postDownloadNotification(.progress) + + // Post detailed episode progress if applicable + if let download = activeDownloads.first(where: { $0.id == downloadID }), + let episodeNumber = download.metadata?.episode { + let status = progress >= 1.0 ? "completed" : "downloading" + postDownloadNotification(.progress, userInfo: [ + "episodeNumber": episodeNumber, + "progress": progress, + "status": status + ]) + } + } } private func setupProgressObservation(for task: URLSessionDownloadTask, downloadID: UUID) { @@ -253,10 +204,126 @@ extension JSController { private func cleanupDownloadResources(for downloadID: UUID) { mp4ProgressObservations?[downloadID] = nil + mp4CustomSessions?[downloadID]?.invalidateAndCancel() mp4CustomSessions?[downloadID] = nil } } +// MARK: - URLSessionDownloadDelegate for MP4 Downloads +extension JSController: URLSessionDownloadDelegate { + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + // Check if this is an MP4 download by checking if we have a custom session for it + guard let downloadID = activeDownloadMap[downloadTask] else { + // If not found in our mapping, it might be an AVAssetDownloadTask + // Let the existing AVAssetDownloadDelegate handle it + return + } + + guard let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { + print("MP4 Download: Couldn't find download for completed task") + return + } + + // Check if this download was cancelled + if cancelledDownloadIDs.contains(downloadID) { + print("MP4 Download: Ignoring completion for cancelled download") + try? FileManager.default.removeItem(at: location) + removeActiveDownload(downloadID: downloadID) + return + } + + let download = activeDownloads[downloadIndex] + + // Move file to final destination + guard let downloadDirectory = getPersistentDownloadDirectory() else { + print("MP4 Download: Failed to get download directory") + removeActiveDownload(downloadID: downloadID) + return + } + + let sanitizedTitle = download.title?.replacingOccurrences(of: "[^A-Za-z0-9 ._-]", with: "", options: .regularExpression) ?? "download" + let filename = "\(sanitizedTitle)_\(downloadID.uuidString.prefix(8)).mp4" + let destinationURL = downloadDirectory.appendingPathComponent(filename) + + do { + if FileManager.default.fileExists(atPath: destinationURL.path) { + try FileManager.default.removeItem(at: destinationURL) + } + + try FileManager.default.moveItem(at: location, 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: download.title ?? download.originalURL.lastPathComponent, + downloadDate: Date(), + originalURL: download.originalURL, + localURL: destinationURL, + type: download.type, + metadata: download.metadata, + subtitleURL: download.subtitleURL + ) + + // Save asset + savedAssets.append(downloadedAsset) + saveAssets() + + // Update progress to complete + updateDownloadProgress(downloadID: downloadID, progress: 1.0) + + // Download subtitle if provided + if let subtitleURL = download.subtitleURL { + downloadSubtitle(subtitleURL: subtitleURL, assetID: downloadedAsset.id.uuidString) + } + + // Notify completion + postDownloadNotification(.completed) + + // Clean up after a brief delay + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.removeActiveDownload(downloadID: downloadID) + } + + } catch { + print("MP4 Download Error saving file: \(error.localizedDescription)") + removeActiveDownload(downloadID: downloadID) + } + } + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + // Check if this is one of our MP4 downloads + guard let downloadID = activeDownloadMap[downloadTask] else { return } + + // Calculate progress + let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0.0 + + DispatchQueue.main.async { + self.updateDownloadProgress(downloadID: downloadID, progress: progress) + } + } + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) { + // Handle resume for MP4 downloads + guard let downloadID = activeDownloadMap[downloadTask] else { return } + + let progress = expectedTotalBytes > 0 ? Double(fileOffset) / Double(expectedTotalBytes) : 0.0 + + DispatchQueue.main.async { + self.updateDownloadProgress(downloadID: downloadID, progress: progress) + self.postDownloadNotification(.statusChange) + } + + print("MP4 Download: Resumed at offset \(fileOffset) of \(expectedTotalBytes)") + } +} + // MARK: - URLSessionDelegate extension JSController: URLSessionDelegate { func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { diff --git a/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift b/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift index a6e9f6b..a5c52a7 100644 --- a/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift +++ b/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift @@ -1124,9 +1124,32 @@ extension JSController { print("Cancelled active download: \(downloadTitle)") } } + + var mp4CustomSessions: [UUID: URLSession]? { + get { + return objc_getAssociatedObject(self, &AssociatedKeys.mp4CustomSessions) as? [UUID: URLSession] + } + set { + objc_setAssociatedObject(self, &AssociatedKeys.mp4CustomSessions, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + var mp4ProgressObservations: [UUID: NSKeyValueObservation]? { + get { + return objc_getAssociatedObject(self, &AssociatedKeys.mp4ProgressObservations) as? [UUID: NSKeyValueObservation] + } + set { + objc_setAssociatedObject(self, &AssociatedKeys.mp4ProgressObservations, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } } -// MARK: - AVAssetDownloadDelegate +private struct AssociatedKeys { + static var mp4CustomSessions = "mp4CustomSessions" + static var mp4ProgressObservations = "mp4ProgressObservations" +} + + extension JSController: AVAssetDownloadDelegate { /// Called when a download task finishes downloading the asset @@ -1552,4 +1575,4 @@ enum DownloadQueueStatus: Equatable { case downloading /// Download has been completed case completed -} \ No newline at end of file +} \ No newline at end of file diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index aa38615..c4cd90b 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -729,6 +729,7 @@ struct EnhancedActiveDownloadCard: View { let download: JSActiveDownload @State private var currentProgress: Double @State private var taskState: URLSessionTask.State + @State private var progressUpdateTimer: Timer? init(download: JSActiveDownload) { self.download = download @@ -842,6 +843,17 @@ struct EnhancedActiveDownloadCard: View { .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadProgressChanged"))) { _ in updateProgress() } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadStatusChanged"))) { _ in + updateStatus() + } + .onAppear { + updateProgress() + updateStatus() + startProgressTimer() + } + .onDisappear { + stopProgressTimer() + } } private var statusColor: Color { @@ -849,8 +861,10 @@ struct EnhancedActiveDownloadCard: View { return .orange } else if taskState == .running { return .green - } else { + } else if taskState == .suspended { return .orange + } else { + return .red } } @@ -859,30 +873,54 @@ struct EnhancedActiveDownloadCard: View { return "Queued" } else if taskState == .running { return "Downloading" - } else { + } else if taskState == .suspended { return "Paused" + } else { + return "Stopped" } } + private func startProgressTimer() { + progressUpdateTimer?.invalidate() + progressUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in + updateProgress() + updateStatus() + } + } + + private func stopProgressTimer() { + progressUpdateTimer?.invalidate() + progressUpdateTimer = nil + } + private func updateProgress() { if let currentDownload = JSController.shared.activeDownloads.first(where: { $0.id == download.id }) { - withAnimation(.easeInOut(duration: 0.1)) { + withAnimation(.easeInOut(duration: 0.2)) { currentProgress = currentDownload.progress } - if let task = currentDownload.task { - taskState = task.state - } + } + } + + private func updateStatus() { + if let currentDownload = JSController.shared.activeDownloads.first(where: { $0.id == download.id }), + let task = currentDownload.task { + taskState = task.state } } private func toggleDownload() { + guard let task = download.task else { return } + if taskState == .running { - download.task?.suspend() + task.suspend() taskState = .suspended } else if taskState == .suspended { - download.task?.resume() + task.resume() taskState = .running } + + // Post status change notification + NotificationCenter.default.post(name: NSNotification.Name("downloadStatusChanged"), object: nil) } private func cancelDownload() {