fix: concurrent downloads

This commit is contained in:
Fredrik Burmester
2025-02-17 15:36:48 +01:00
parent 16361d40df
commit db6bc7901e
4 changed files with 145 additions and 130 deletions

View File

@@ -25,7 +25,6 @@ const getETA = (download: DownloadInfo): string | null => {
!download.secondsDownloaded || !download.secondsDownloaded ||
!download.secondsTotal !download.secondsTotal
) { ) {
console.log(download);
return null; return null;
} }
const elapsed = Date.now() / 1000 - download.startTime; const elapsed = Date.now() / 1000 - download.startTime;

View File

@@ -1,13 +1,67 @@
import AVFoundation import AVFoundation
import ExpoModulesCore import ExpoModulesCore
class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
weak var module: HlsDownloaderModule?
var taskIdentifier: Int = 0
var providedId: String = ""
var downloadedSeconds: Double = 0
var totalSeconds: Double = 0
var startTime: Double = 0
init(module: HlsDownloaderModule) {
self.module = module
super.init()
}
public func urlSession(
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange,
totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange
) {
module?.urlSession(
session, assetDownloadTask: assetDownloadTask, didLoad: timeRange,
totalTimeRangesLoaded: loadedTimeRanges, timeRangeExpectedToLoad: timeRangeExpectedToLoad)
}
public func urlSession(
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
didFinishDownloadingTo location: URL
) {
module?.urlSession(
session, assetDownloadTask: assetDownloadTask, didFinishDownloadingTo: location)
}
public func urlSession(
_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?
) {
module?.urlSession(session, task: task, didCompleteWithError: error)
}
}
public class HlsDownloaderModule: Module { public class HlsDownloaderModule: Module {
private lazy var delegateHandler: HlsDownloaderDelegate = {
return HlsDownloaderDelegate(module: self)
}()
var activeDownloads: var activeDownloads:
[Int: ( [Int: (
task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any], task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any],
startTime: Double startTime: Double
)] = [:] )] = [:]
private lazy var downloadSession: AVAssetDownloadURLSession = {
let configuration = URLSessionConfiguration.background(
withIdentifier: "com.example.hlsdownload")
configuration.allowsCellularAccess = true
configuration.sessionSendsLaunchEvents = true
configuration.isDiscretionary = false
return AVAssetDownloadURLSession(
configuration: configuration,
assetDownloadDelegate: delegateHandler,
delegateQueue: OperationQueue.main
)
}()
public func definition() -> ModuleDefinition { public func definition() -> ModuleDefinition {
Name("HlsDownloader") Name("HlsDownloader")
@@ -16,20 +70,15 @@ public class HlsDownloaderModule: Module {
Function("downloadHLSAsset") { Function("downloadHLSAsset") {
(providedId: String, url: String, metadata: [String: Any]?) -> Void in (providedId: String, url: String, metadata: [String: Any]?) -> Void in
let startTime = Date().timeIntervalSince1970 let startTime = Date().timeIntervalSince1970
// First check if the asset already exists
let fm = FileManager.default let fm = FileManager.default
let docs = fm.urls(for: .documentDirectory, in: .userDomainMask)[0] let docs = fm.urls(for: .documentDirectory, in: .userDomainMask)[0]
let downloadsDir = docs.appendingPathComponent("downloads", isDirectory: true) let downloadsDir = docs.appendingPathComponent("downloads", isDirectory: true)
let potentialExistingLocation = downloadsDir.appendingPathComponent( let potentialExistingLocation = downloadsDir.appendingPathComponent(
providedId, isDirectory: true) providedId, isDirectory: true)
if fm.fileExists(atPath: potentialExistingLocation.path) { if fm.fileExists(atPath: potentialExistingLocation.path) {
// Check if the download is complete by looking for the master playlist
if let files = try? fm.contentsOfDirectory(atPath: potentialExistingLocation.path), if let files = try? fm.contentsOfDirectory(atPath: potentialExistingLocation.path),
files.contains(where: { $0.hasSuffix(".m3u8") }) files.contains(where: { $0.hasSuffix(".m3u8") })
{ {
// Asset exists and appears complete, send completion event
self.sendEvent( self.sendEvent(
"onComplete", "onComplete",
[ [
@@ -41,7 +90,6 @@ public class HlsDownloaderModule: Module {
]) ])
return return
} else { } else {
// Asset exists but appears incomplete, clean it up
try? fm.removeItem(at: potentialExistingLocation) try? fm.removeItem(at: potentialExistingLocation)
} }
} }
@@ -59,7 +107,6 @@ public class HlsDownloaderModule: Module {
return return
} }
// Rest of the download logic remains the same
let asset = AVURLAsset( let asset = AVURLAsset(
url: assetURL, url: assetURL,
options: [ options: [
@@ -87,31 +134,16 @@ public class HlsDownloaderModule: Module {
return return
} }
let configuration = URLSessionConfiguration.background(
withIdentifier: "com.streamyfin.hlsdownload") // Add unique identifier
configuration.allowsCellularAccess = true
configuration.sessionSendsLaunchEvents = true
configuration.isDiscretionary = false
let delegate = HLSDownloadDelegate(module: self)
delegate.providedId = providedId
delegate.startTime = startTime
let downloadSession = AVAssetDownloadURLSession(
configuration: configuration,
assetDownloadDelegate: delegate,
delegateQueue: OperationQueue.main
)
guard guard
let task = downloadSession.makeAssetDownloadTask( let task = self.downloadSession.makeAssetDownloadTask(
asset: asset, asset: asset,
assetTitle: providedId, assetTitle: providedId,
assetArtworkData: nil, assetArtworkData: nil,
options: [ options: [
AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 265_000, AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 265_000,
AVAssetDownloadTaskMinimumRequiredPresentationSizeKey: NSValue( AVAssetDownloadTaskMinimumRequiredPresentationSizeKey: NSValue(
cgSize: CGSize(width: 480, height: 360)), cgSize: CGSize(width: 480, height: 360)
),
] ]
) )
else { else {
@@ -127,8 +159,13 @@ public class HlsDownloaderModule: Module {
return return
} }
let delegate = HLSDownloadDelegate(module: self)
delegate.providedId = providedId
delegate.startTime = startTime
delegate.taskIdentifier = task.taskIdentifier delegate.taskIdentifier = task.taskIdentifier
self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime) self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime)
self.sendEvent( self.sendEvent(
"onProgress", "onProgress",
[ [
@@ -185,24 +222,18 @@ public class HlsDownloaderModule: Module {
try fm.createDirectory(at: downloadsDir, withIntermediateDirectories: true) try fm.createDirectory(at: downloadsDir, withIntermediateDirectories: true)
} }
let newLocation = downloadsDir.appendingPathComponent(folderName, isDirectory: true) let newLocation = downloadsDir.appendingPathComponent(folderName, isDirectory: true)
// New atomic move implementation
let tempLocation = downloadsDir.appendingPathComponent("\(folderName)_temp", isDirectory: true) let tempLocation = downloadsDir.appendingPathComponent("\(folderName)_temp", isDirectory: true)
// Clean up any existing temp folder
if fm.fileExists(atPath: tempLocation.path) { if fm.fileExists(atPath: tempLocation.path) {
try fm.removeItem(at: tempLocation) try fm.removeItem(at: tempLocation)
} }
// Move to temp location first
try fm.moveItem(at: originalLocation, to: tempLocation) try fm.moveItem(at: originalLocation, to: tempLocation)
// If target exists, remove it
if fm.fileExists(atPath: newLocation.path) { if fm.fileExists(atPath: newLocation.path) {
try fm.removeItem(at: newLocation) try fm.removeItem(at: newLocation)
} }
// Final move from temp to target
try fm.moveItem(at: tempLocation, to: newLocation) try fm.moveItem(at: tempLocation, to: newLocation)
return newLocation return newLocation
@@ -219,48 +250,69 @@ public class HlsDownloaderModule: Module {
} }
} }
} }
class HlsDownloaderDelegate: NSObject, AVAssetDownloadDelegate {
class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
weak var module: HlsDownloaderModule? weak var module: HlsDownloaderModule?
var taskIdentifier: Int = 0 var taskIdentifier: Int = 0
var providedId: String = "" var providedId: String = ""
var downloadedSeconds: Double = 0 var downloadedSeconds: Double = 0
var totalSeconds: Double = 0 var totalSeconds: Double = 0
var startTime: Double = 0 var startTime: Double = 0
private var wasCancelled = false
init(module: HlsDownloaderModule) { init(module: HlsDownloaderModule) {
self.module = module self.module = module
super.init()
} }
public func urlSession(
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange,
totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange
) {
module?.urlSession(
session, assetDownloadTask: assetDownloadTask, didLoad: timeRange,
totalTimeRangesLoaded: loadedTimeRanges, timeRangeExpectedToLoad: timeRangeExpectedToLoad)
}
public func urlSession(
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
didFinishDownloadingTo location: URL
) {
module?.urlSession(
session, assetDownloadTask: assetDownloadTask, didFinishDownloadingTo: location)
}
public func urlSession(
_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?
) {
module?.urlSession(session, task: task, didCompleteWithError: error)
}
}
extension HlsDownloaderModule {
func urlSession( func urlSession(
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, _ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange,
totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange
) { ) {
guard let downloadInfo = activeDownloads[assetDownloadTask.taskIdentifier] else { return }
let downloaded = loadedTimeRanges.reduce(0.0) { total, value in let downloaded = loadedTimeRanges.reduce(0.0) { total, value in
let timeRange = value.timeRangeValue let timeRange = value.timeRangeValue
return total + CMTimeGetSeconds(timeRange.duration) return total + CMTimeGetSeconds(timeRange.duration)
} }
let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration) let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration)
let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
let startTime = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.startTime ?? 0
self.downloadedSeconds = downloaded
self.totalSeconds = total
let progress = total > 0 ? downloaded / total : 0 let progress = total > 0 ? downloaded / total : 0
module?.sendEvent( sendEvent(
"onProgress", "onProgress",
[ [
"id": providedId, "id": downloadInfo.delegate.providedId,
"progress": progress, "progress": progress,
"secondsDownloaded": downloaded, "secondsDownloaded": downloaded,
"secondsTotal": total, "secondsTotal": total,
"state": progress >= 1.0 ? "DONE" : "DOWNLOADING", "state": progress >= 1.0 ? "DONE" : "DOWNLOADING",
"metadata": metadata, "metadata": downloadInfo.metadata,
"startTime": startTime, "startTime": downloadInfo.startTime,
"taskId": assetDownloadTask.taskIdentifier,
]) ])
} }
@@ -268,110 +320,82 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, _ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
didFinishDownloadingTo location: URL didFinishDownloadingTo location: URL
) { ) {
if wasCancelled { guard let downloadInfo = activeDownloads[assetDownloadTask.taskIdentifier] else { return }
return
}
let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
let startTime = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.startTime ?? 0
let folderName = providedId
guard let module = module else { return }
// Calculate download size
// let fileManager = FileManager.default
// let enumerator = fileManager.enumerator(
// at: newLocation,
// includingPropertiesForKeys: [.totalFileAllocatedSizeKey],
// options: [.skipsHiddenFiles],
// errorHandler: nil)!
// var totalSize: Int64 = 0
// while let filePath = enumerator.nextObject() as? URL {
// do {
// let resourceValues = try filePath.resourceValues(forKeys: [.totalFileAllocatedSizeKey])
// if let size = resourceValues.totalFileAllocatedSize {
// totalSize += Int64(size)
// }
// } catch {
// print("Error calculating size: \(error)")
// }
// }
do { do {
let newLocation = try module.persistDownloadedFolder( let newLocation = try persistDownloadedFolder(
originalLocation: location, folderName: folderName) originalLocation: location, folderName: downloadInfo.delegate.providedId)
// Handle metadata first if !downloadInfo.metadata.isEmpty {
if !metadata.isEmpty {
let metadataLocation = newLocation.deletingLastPathComponent().appendingPathComponent( let metadataLocation = newLocation.deletingLastPathComponent().appendingPathComponent(
"\(providedId).json") "\(downloadInfo.delegate.providedId).json")
let jsonData = try JSONSerialization.data( let jsonData = try JSONSerialization.data(
withJSONObject: metadata, options: .prettyPrinted) withJSONObject: downloadInfo.metadata, options: .prettyPrinted)
try jsonData.write(to: metadataLocation) try jsonData.write(to: metadataLocation)
} }
// Create a new Task for async operation
Task { Task {
do { do {
try await rewriteM3U8Files(baseDir: newLocation.path) try await rewriteM3U8Files(baseDir: newLocation.path)
module.sendEvent( sendEvent(
"onComplete", "onComplete",
[ [
"id": providedId, "id": downloadInfo.delegate.providedId,
"location": newLocation.absoluteString, "location": newLocation.absoluteString,
"state": "DONE", "state": "DONE",
"metadata": metadata, "metadata": downloadInfo.metadata,
"startTime": startTime, "startTime": downloadInfo.startTime,
]) ])
} catch { } catch {
module.sendEvent( sendEvent(
"onError", "onError",
[ [
"id": providedId, "id": downloadInfo.delegate.providedId,
"error": error.localizedDescription, "error": error.localizedDescription,
"state": "FAILED", "state": "FAILED",
"metadata": metadata, "metadata": downloadInfo.metadata,
"startTime": startTime, "startTime": downloadInfo.startTime,
]) ])
} }
} }
} catch { } catch {
module.sendEvent( sendEvent(
"onError", "onError",
[ [
"id": providedId, "id": downloadInfo.delegate.providedId,
"error": error.localizedDescription, "error": error.localizedDescription,
"state": "FAILED", "state": "FAILED",
"metadata": metadata, "metadata": downloadInfo.metadata,
"startTime": startTime, "startTime": downloadInfo.startTime,
]) ])
} }
module.removeDownload(with: assetDownloadTask.taskIdentifier) removeDownload(with: assetDownloadTask.taskIdentifier)
} }
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { func urlSession(
if let error = error { _ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?
if (error as NSError).code == NSURLErrorCancelled { ) {
wasCancelled = true guard let error = error,
module?.removeDownload(with: taskIdentifier) let downloadInfo = activeDownloads[task.taskIdentifier]
return else { return }
}
let metadata = module?.activeDownloads[task.taskIdentifier]?.metadata ?? [:] if (error as NSError).code == NSURLErrorCancelled {
let startTime = module?.activeDownloads[task.taskIdentifier]?.startTime ?? 0 removeDownload(with: task.taskIdentifier)
module?.sendEvent( return
"onError",
[
"id": providedId,
"error": error.localizedDescription,
"state": "FAILED",
"metadata": metadata,
"startTime": startTime,
])
module?.removeDownload(with: taskIdentifier)
} }
sendEvent(
"onError",
[
"id": downloadInfo.delegate.providedId,
"error": error.localizedDescription,
"state": "FAILED",
"metadata": downloadInfo.metadata,
"startTime": downloadInfo.startTime,
])
removeDownload(with: task.taskIdentifier)
} }
} }

View File

@@ -30,6 +30,7 @@ export type OnProgressEventPayload = BaseEventPayload & {
progress: number; progress: number;
secondsDownloaded: number; secondsDownloaded: number;
secondsTotal: number; secondsTotal: number;
taskId?: number;
}; };
export type OnErrorEventPayload = BaseEventPayload & { export type OnErrorEventPayload = BaseEventPayload & {

View File

@@ -151,7 +151,8 @@ export const NativeDownloadProvider: React.FC<{
"[HLS] Download progress:", "[HLS] Download progress:",
download.metadata.item.Id, download.metadata.item.Id,
download.progress, download.progress,
download.state download.state,
download.taskId
); );
setDownloads((prev) => ({ setDownloads((prev) => ({
@@ -166,26 +167,15 @@ export const NativeDownloadProvider: React.FC<{
startTime: download?.startTime, startTime: download?.startTime,
}, },
})); }));
});
const completeListener = addCompleteListener(async (payload) => { if (download.state === "DONE") {
try { refetchDownloadedFiles();
// await rewriteM3U8Files(payload.location);
// await markFileAsDone(payload.id);
console.log("completeListener", payload.id);
setDownloads((prev) => { setDownloads((prev) => {
const newDownloads = { ...prev }; const newDownloads = { ...prev };
delete newDownloads[payload.id]; delete newDownloads[download.id];
return newDownloads; return newDownloads;
}); });
if (payload.state === "DONE") toast.success("Download complete ✅");
refetchDownloadedFiles();
} catch (error) {
console.error("Failed to download file:", error);
toast.error("Failed to download ❌");
} }
}); });
@@ -197,15 +187,16 @@ export const NativeDownloadProvider: React.FC<{
}); });
if (error.state === "CANCELLED") toast.info("Download cancelled 🟡"); if (error.state === "CANCELLED") toast.info("Download cancelled 🟡");
else { else if (error.state === "FAILED") {
toast.error("Download failed ❌"); toast.error("Download failed ❌");
console.error("Download error:", error); console.error("Download error:", error);
} else {
console.error("errorListener fired with unknown state:", error);
} }
}); });
return () => { return () => {
progressListener.remove(); progressListener.remove();
completeListener.remove();
errorListener.remove(); errorListener.remove();
}; };
}, []); }, []);