This commit is contained in:
Francesco 2025-05-31 22:00:03 +02:00
parent e9d9101526
commit e14b00fc13
3 changed files with 252 additions and 124 deletions

View file

@ -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) {

View file

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

View file

@ -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() {