Files
streamyfin/modules/hls-downloader/ios/HlsDownloaderModule.swift
2025-02-17 20:28:05 +01:00

420 lines
13 KiB
Swift

import AVFoundation
import ExpoModulesCore
import UserNotifications
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 {
private lazy var delegateHandler: HLSDownloadDelegate = {
return HLSDownloadDelegate(module: self)
}()
var activeDownloads:
[Int: (
task: AVAssetDownloadTask,
delegate: HLSDownloadDelegate,
metadata: [String: Any],
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 {
Name("HlsDownloader")
Events("onProgress", "onError", "onComplete")
// Function("requestNotificationPermission") { () -> Bool in
// var permissionGranted = false
// let semaphore = DispatchSemaphore(value: 0)
// UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) {
// granted, error in
// permissionGranted = granted
// semaphore.signal()
// }
// _ = semaphore.wait(timeout: .now() + 5.0)
// return permissionGranted
// }
Function("getActiveDownloads") { () -> [[String: Any]] in
return activeDownloads.map { (taskId, downloadInfo) in
return [
"id": downloadInfo.delegate.providedId,
"state": "DOWNLOADING",
"metadata": downloadInfo.metadata,
"startTime": downloadInfo.startTime,
"taskId": taskId,
]
}
}
Function("downloadHLSAsset") {
(providedId: String, url: String, metadata: [String: Any]?) -> Void in
let startTime = Date().timeIntervalSince1970
let fm = FileManager.default
let docs = fm.urls(for: .documentDirectory, in: .userDomainMask)[0]
let downloadsDir = docs.appendingPathComponent("downloads", isDirectory: true)
let potentialExistingLocation = downloadsDir.appendingPathComponent(
providedId, isDirectory: true)
if fm.fileExists(atPath: potentialExistingLocation.path) {
if let files = try? fm.contentsOfDirectory(atPath: potentialExistingLocation.path),
files.contains(where: { $0.hasSuffix(".m3u8") })
{
self.sendEvent(
"onComplete",
[
"id": providedId,
"location": potentialExistingLocation.absoluteString,
"state": "DONE",
"metadata": metadata ?? [:],
"startTime": startTime,
])
return
} else {
try? fm.removeItem(at: potentialExistingLocation)
}
}
guard let assetURL = URL(string: url) else {
self.sendEvent(
"onError",
[
"id": providedId,
"error": "Invalid URL",
"state": "FAILED",
"metadata": metadata ?? [:],
"startTime": startTime,
])
return
}
let asset = AVURLAsset(
url: assetURL,
options: [
"AVURLAssetOutOfBandMIMETypeKey": "application/x-mpegURL",
"AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "MyApp/1.0"],
"AVURLAssetAllowsCellularAccessKey": true,
])
asset.loadValuesAsynchronously(forKeys: ["playable", "duration"]) {
var error: NSError?
let status = asset.statusOfValue(forKey: "playable", error: &error)
DispatchQueue.main.async {
if status == .failed || error != nil {
self.sendEvent(
"onError",
[
"id": providedId,
"error":
"Asset validation failed: \(error?.localizedDescription ?? "Unknown error")",
"state": "FAILED",
"metadata": metadata ?? [:],
"startTime": startTime,
])
return
}
guard
let task = self.downloadSession.makeAssetDownloadTask(
asset: asset,
assetTitle: providedId,
assetArtworkData: nil,
options: [
AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 265_000,
AVAssetDownloadTaskMinimumRequiredPresentationSizeKey: NSValue(
cgSize: CGSize(width: 480, height: 360)
),
]
)
else {
self.sendEvent(
"onError",
[
"id": providedId,
"error": "Failed to create download task",
"state": "FAILED",
"metadata": metadata ?? [:],
"startTime": startTime,
])
return
}
let delegate = HLSDownloadDelegate(module: self)
delegate.providedId = providedId
delegate.startTime = startTime
delegate.taskIdentifier = task.taskIdentifier
self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime)
self.sendEvent(
"onProgress",
[
"id": providedId,
"progress": 0.0,
"state": "PENDING",
"metadata": metadata ?? [:],
"startTime": startTime,
])
task.resume()
}
}
}
Function("cancelDownload") { (providedId: String) -> Void in
guard
let entry = self.activeDownloads.first(where: { $0.value.delegate.providedId == providedId }
)
else {
return
}
let (task, _, metadata, startTime) = entry.value
self.sendEvent(
"onError",
[
"id": providedId,
"error": "Download cancelled",
"state": "CANCELLED",
"metadata": metadata,
"startTime": startTime,
])
task.cancel()
self.activeDownloads.removeValue(forKey: task.taskIdentifier)
}
}
func removeDownload(with id: Int) {
activeDownloads.removeValue(forKey: id)
}
private func sendDownloadCompletionNotification(title: String, body: String) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: trigger
)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Error showing notification: \(error)")
}
}
}
func persistDownloadedFolder(originalLocation: URL, folderName: String) throws -> URL {
let fm = FileManager.default
let docs = fm.urls(for: .documentDirectory, in: .userDomainMask)[0]
let downloadsDir = docs.appendingPathComponent("downloads", isDirectory: true)
if !fm.fileExists(atPath: downloadsDir.path) {
try fm.createDirectory(at: downloadsDir, withIntermediateDirectories: true)
}
let newLocation = downloadsDir.appendingPathComponent(folderName, isDirectory: true)
let tempLocation = downloadsDir.appendingPathComponent("\(folderName)_temp", isDirectory: true)
if fm.fileExists(atPath: tempLocation.path) {
try fm.removeItem(at: tempLocation)
}
try fm.moveItem(at: originalLocation, to: tempLocation)
if fm.fileExists(atPath: newLocation.path) {
try fm.removeItem(at: newLocation)
}
try fm.moveItem(at: tempLocation, to: newLocation)
return newLocation
}
}
extension HlsDownloaderModule {
func urlSession(
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange,
totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange
) {
guard let downloadInfo = activeDownloads[assetDownloadTask.taskIdentifier] else { return }
let downloaded = loadedTimeRanges.reduce(0.0) { total, value in
let timeRange = value.timeRangeValue
return total + CMTimeGetSeconds(timeRange.duration)
}
let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration)
let progress = total > 0 ? downloaded / total : 0
sendEvent(
"onProgress",
[
"id": downloadInfo.delegate.providedId,
"progress": progress,
"secondsDownloaded": downloaded,
"secondsTotal": total,
"state": progress >= 1.0 ? "DONE" : "DOWNLOADING",
"metadata": downloadInfo.metadata,
"startTime": downloadInfo.startTime,
"taskId": assetDownloadTask.taskIdentifier,
])
}
func urlSession(
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
didFinishDownloadingTo location: URL
) {
guard let downloadInfo = activeDownloads[assetDownloadTask.taskIdentifier] else { return }
do {
let newLocation = try persistDownloadedFolder(
originalLocation: location,
folderName: downloadInfo.delegate.providedId)
if !downloadInfo.metadata.isEmpty {
let metadataLocation = newLocation.deletingLastPathComponent().appendingPathComponent(
"\(downloadInfo.delegate.providedId).json")
let jsonData = try JSONSerialization.data(
withJSONObject: downloadInfo.metadata,
options: .prettyPrinted)
try jsonData.write(to: metadataLocation)
}
Task {
do {
try await rewriteM3U8Files(baseDir: newLocation.path)
// Safely access metadata for notification
let notificationBody: String
if let item = downloadInfo.metadata["item"] as? [String: Any],
let name = item["Name"] as? String
{
notificationBody = "\(name) has finished downloading."
} else {
notificationBody = "Download completed successfully."
}
sendDownloadCompletionNotification(
title: "Download Complete",
body: notificationBody
)
sendEvent(
"onComplete",
[
"id": downloadInfo.delegate.providedId,
"location": newLocation.absoluteString,
"state": "DONE",
"metadata": downloadInfo.metadata,
"startTime": downloadInfo.startTime,
])
} catch {
sendEvent(
"onError",
[
"id": downloadInfo.delegate.providedId,
"error": error.localizedDescription,
"state": "FAILED",
"metadata": downloadInfo.metadata,
"startTime": downloadInfo.startTime,
])
}
}
} catch {
sendEvent(
"onError",
[
"id": downloadInfo.delegate.providedId,
"error": error.localizedDescription,
"state": "FAILED",
"metadata": downloadInfo.metadata,
"startTime": downloadInfo.startTime,
])
}
removeDownload(with: assetDownloadTask.taskIdentifier)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard let error = error,
let downloadInfo = activeDownloads[task.taskIdentifier]
else { return }
if (error as NSError).code == NSURLErrorCancelled {
removeDownload(with: task.taskIdentifier)
return
}
sendEvent(
"onError",
[
"id": downloadInfo.delegate.providedId,
"error": error.localizedDescription,
"state": "FAILED",
"metadata": downloadInfo.metadata,
"startTime": downloadInfo.startTime,
])
removeDownload(with: task.taskIdentifier)
}
}