Files
streamyfin/modules/background-downloader/ios/BackgroundDownloaderModule.swift
2025-11-11 08:53:23 +01:00

398 lines
11 KiB
Swift

import ExpoModulesCore
import Foundation
enum DownloadError: Error {
case invalidURL
case fileOperationFailed
case downloadFailed
}
struct DownloadTaskInfo {
let url: String
let destinationPath: String?
}
// Separate delegate class to handle URLSession callbacks
class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate {
weak var module: BackgroundDownloaderModule?
init(module: BackgroundDownloaderModule) {
self.module = module
super.init()
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
module?.handleProgress(
taskId: downloadTask.taskIdentifier,
bytesWritten: totalBytesWritten,
totalBytes: totalBytesExpectedToWrite
)
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
module?.handleDownloadComplete(
taskId: downloadTask.taskIdentifier,
location: location,
downloadTask: downloadTask
)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
if let error = error {
print("[BackgroundDownloader] Task \(task.taskIdentifier) error: \(error.localizedDescription)")
module?.handleError(taskId: task.taskIdentifier, error: error)
}
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
DispatchQueue.main.async {
if let completion = BackgroundDownloaderModule.backgroundCompletionHandler {
completion()
BackgroundDownloaderModule.backgroundCompletionHandler = nil
}
}
}
}
public class BackgroundDownloaderModule: Module {
private var session: URLSession?
private var sessionDelegate: DownloadSessionDelegate?
fileprivate static var backgroundCompletionHandler: (() -> Void)?
private var downloadTasks: [Int: DownloadTaskInfo] = [:]
private var downloadQueue: [(url: String, destinationPath: String?)] = []
private var lastProgressTime: [Int: Date] = [:]
public func definition() -> ModuleDefinition {
Name("BackgroundDownloader")
Events(
"onDownloadProgress",
"onDownloadComplete",
"onDownloadError",
"onDownloadStarted"
)
OnCreate {
self.initializeSession()
}
AsyncFunction("startDownload") { (urlString: String, destinationPath: String?) -> Int in
guard let url = URL(string: urlString) else {
throw DownloadError.invalidURL
}
if self.session == nil {
self.initializeSession()
}
guard let session = self.session else {
throw DownloadError.downloadFailed
}
// Create a URLRequest to ensure proper handling
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = 300
let task = session.downloadTask(with: request)
let taskId = task.taskIdentifier
self.downloadTasks[taskId] = DownloadTaskInfo(
url: urlString,
destinationPath: destinationPath
)
task.resume()
self.sendEvent("onDownloadStarted", [
"taskId": taskId,
"url": urlString
])
return taskId
}
AsyncFunction("enqueueDownload") { (urlString: String, destinationPath: String?) -> Int in
// Add to queue
let wasEmpty = self.downloadQueue.isEmpty
self.downloadQueue.append((url: urlString, destinationPath: destinationPath))
// If queue was empty and no active downloads, start processing immediately
if wasEmpty {
return try await self.processNextInQueue()
}
// Return placeholder taskId for queued items
return -1
}
Function("cancelDownload") { (taskId: Int) in
self.session?.getAllTasks { tasks in
for task in tasks where task.taskIdentifier == taskId {
task.cancel()
self.downloadTasks.removeValue(forKey: taskId)
}
}
}
Function("cancelQueuedDownload") { (url: String) in
// Remove from queue by URL
self.downloadQueue.removeAll { queuedItem in
queuedItem.url == url
}
}
Function("cancelAllDownloads") {
self.session?.getAllTasks { tasks in
for task in tasks {
task.cancel()
}
self.downloadTasks.removeAll()
}
}
AsyncFunction("getActiveDownloads") { () -> [[String: Any]] in
return try await withCheckedThrowingContinuation { continuation in
let downloadTasks = self.downloadTasks
self.session?.getAllTasks { tasks in
let activeDownloads = tasks.compactMap { task -> [String: Any]? in
guard task is URLSessionDownloadTask,
let info = downloadTasks[task.taskIdentifier] else {
return nil
}
return [
"taskId": task.taskIdentifier,
"url": info.url
]
}
continuation.resume(returning: activeDownloads)
}
}
}
}
private func initializeSession() {
print("[BackgroundDownloader] Initializing URLSession")
let config = URLSessionConfiguration.background(
withIdentifier: "com.fredrikburmester.streamyfin.backgrounddownloader"
)
config.allowsCellularAccess = true
config.sessionSendsLaunchEvents = true
config.isDiscretionary = false
self.sessionDelegate = DownloadSessionDelegate(module: self)
self.session = URLSession(
configuration: config,
delegate: self.sessionDelegate,
delegateQueue: nil
)
print("[BackgroundDownloader] URLSession initialized with delegate: \(String(describing: self.sessionDelegate))")
print("[BackgroundDownloader] Session identifier: \(config.identifier ?? "nil")")
print("[BackgroundDownloader] Delegate queue: nil (uses default)")
// Verify delegate is connected
if let session = self.session, session.delegate != nil {
print("[BackgroundDownloader] ✅ Delegate successfully attached to session")
} else {
print("[BackgroundDownloader] ⚠️ DELEGATE NOT ATTACHED!")
}
}
// Handler methods called by the delegate
func handleProgress(taskId: Int, bytesWritten: Int64, totalBytes: Int64) {
let progress = totalBytes > 0
? Double(bytesWritten) / Double(totalBytes)
: 0.0
// Throttle progress updates: only send every 500ms
let lastTime = lastProgressTime[taskId] ?? Date.distantPast
let now = Date()
let timeDiff = now.timeIntervalSince(lastTime)
// Send if 500ms passed
if timeDiff >= 0.5 {
self.sendEvent("onDownloadProgress", [
"taskId": taskId,
"bytesWritten": bytesWritten,
"totalBytes": totalBytes,
"progress": progress
])
lastProgressTime[taskId] = now
}
}
func handleDownloadComplete(taskId: Int, location: URL, downloadTask: URLSessionDownloadTask) {
guard let taskInfo = downloadTasks[taskId] else {
self.sendEvent("onDownloadError", [
"taskId": taskId,
"error": "Download task info not found"
])
return
}
let fileManager = FileManager.default
do {
let destinationURL: URL
if let customPath = taskInfo.destinationPath {
destinationURL = URL(fileURLWithPath: customPath)
} else {
let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
let filename = downloadTask.response?.suggestedFilename
?? downloadTask.originalRequest?.url?.lastPathComponent
?? "download_\(taskId)"
destinationURL = documentsDir.appendingPathComponent(filename)
}
if fileManager.fileExists(atPath: destinationURL.path) {
try fileManager.removeItem(at: destinationURL)
}
let destinationDirectory = destinationURL.deletingLastPathComponent()
if !fileManager.fileExists(atPath: destinationDirectory.path) {
try fileManager.createDirectory(
at: destinationDirectory,
withIntermediateDirectories: true,
attributes: nil
)
}
try fileManager.moveItem(at: location, to: destinationURL)
self.sendEvent("onDownloadComplete", [
"taskId": taskId,
"filePath": destinationURL.path,
"url": taskInfo.url
])
downloadTasks.removeValue(forKey: taskId)
lastProgressTime.removeValue(forKey: taskId)
// Process next item in queue
Task {
do {
_ = try await self.processNextInQueue()
} catch {
print("[BackgroundDownloader] Error processing next: \(error)")
}
}
} catch {
self.sendEvent("onDownloadError", [
"taskId": taskId,
"error": "File operation failed: \(error.localizedDescription)"
])
// Process next item in queue even on error
Task {
do {
_ = try await self.processNextInQueue()
} catch {
print("[BackgroundDownloader] Error processing next: \(error)")
}
}
}
}
func handleError(taskId: Int, error: Error) {
let isCancelled = (error as NSError).code == NSURLErrorCancelled
if !isCancelled {
print("[BackgroundDownloader] Task \(taskId) error: \(error.localizedDescription)")
self.sendEvent("onDownloadError", [
"taskId": taskId,
"error": error.localizedDescription
])
}
downloadTasks.removeValue(forKey: taskId)
lastProgressTime.removeValue(forKey: taskId)
// Process next item in queue (whether cancelled or errored)
Task {
do {
_ = try await self.processNextInQueue()
} catch {
print("[BackgroundDownloader] Error processing next: \(error)")
}
}
}
private func processNextInQueue() async throws -> Int {
// Check if queue has items
guard !downloadQueue.isEmpty else {
return -1
}
// Check if there are active downloads
if !downloadTasks.isEmpty {
return -1
}
// Get next item from queue
let (url, destinationPath) = downloadQueue.removeFirst()
print("[BackgroundDownloader] Starting queued download")
// Start the download using existing startDownload logic
guard let urlObj = URL(string: url) else {
print("[BackgroundDownloader] Invalid URL in queue: \(url)")
return try await processNextInQueue()
}
if session == nil {
initializeSession()
}
guard let session = self.session else {
throw DownloadError.downloadFailed
}
var request = URLRequest(url: urlObj)
request.httpMethod = "GET"
request.timeoutInterval = 300
let task = session.downloadTask(with: request)
let taskId = task.taskIdentifier
downloadTasks[taskId] = DownloadTaskInfo(
url: url,
destinationPath: destinationPath
)
task.resume()
sendEvent("onDownloadStarted", [
"taskId": taskId,
"url": url
])
return taskId
}
static func setBackgroundCompletionHandler(_ handler: @escaping () -> Void) {
BackgroundDownloaderModule.backgroundCompletionHandler = handler
}
}