mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
feat: Expo 54 (new arch) support + new in-house download module (#1174)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> Co-authored-by: sarendsen <coding-mosses0z@icloud.com> Co-authored-by: Lance Chant <13349722+lancechant@users.noreply.github.com> Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
154788cf91
commit
485dc6eeac
@@ -0,0 +1,397 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user