mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-21 00:36:24 +00:00
wip
This commit is contained in:
83
modules/hls-downloader/android/build.gradle
Normal file
83
modules/hls-downloader/android/build.gradle
Normal file
@@ -0,0 +1,83 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'kotlin-android'
|
||||
id 'kotlin-kapt'
|
||||
}
|
||||
|
||||
group = 'expo.modules.hlsdownloader'
|
||||
version = '0.1.0'
|
||||
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
||||
|
||||
apply from: expoModulesCorePlugin
|
||||
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
useCoreDependencies()
|
||||
useExpoPublishing()
|
||||
|
||||
def useManagedAndroidSdkVersions = false
|
||||
if (useManagedAndroidSdkVersions) {
|
||||
useDefaultAndroidSdkVersions()
|
||||
} else {
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath "com.android.tools.build:gradle:7.1.3"
|
||||
}
|
||||
}
|
||||
project.android {
|
||||
compileSdkVersion safeExtGet("compileSdkVersion", 34)
|
||||
defaultConfig {
|
||||
minSdkVersion safeExtGet("minSdkVersion", 21)
|
||||
targetSdkVersion safeExtGet("targetSdkVersion", 34)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
|
||||
|
||||
// Media3 dependencies
|
||||
def media3Version = "1.2.0"
|
||||
implementation "androidx.media3:media3-exoplayer:$media3Version"
|
||||
implementation "androidx.media3:media3-datasource:$media3Version"
|
||||
implementation "androidx.media3:media3-common:$media3Version"
|
||||
implementation "androidx.media3:media3-database:$media3Version"
|
||||
implementation "androidx.media3:media3-decoder:$media3Version"
|
||||
implementation "androidx.media3:media3-ui:$media3Version"
|
||||
|
||||
// Coroutines for background processing
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "expo.modules.hlsdownloader"
|
||||
compileSdkVersion 34
|
||||
defaultConfig {
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 34
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
||||
kotlinOptions {
|
||||
freeCompilerArgs += ["-Xshow-kotlin-compiler-errors"]
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<manifest>
|
||||
</manifest>
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.example.hlsdownloader
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.offline.Download
|
||||
import androidx.media3.exoplayer.offline.DownloadManager
|
||||
import androidx.media3.exoplayer.offline.DownloadService
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.cache.NoOpCacheEvictor
|
||||
import androidx.media3.exoplayer.offline.DownloadRequest
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@UnstableApi
|
||||
class HlsDownloaderModule(private val context: ReactContext) {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val downloadCache: SimpleCache
|
||||
private val downloadManager: DownloadManager
|
||||
private val activeDownloads = ConcurrentHashMap<String, DownloadInfo>()
|
||||
|
||||
data class DownloadInfo(
|
||||
val metadata: Map<String, Any>,
|
||||
val startTime: Long,
|
||||
var downloadRequest: DownloadRequest? = null
|
||||
)
|
||||
|
||||
init {
|
||||
// Initialize download cache
|
||||
val downloadDirectory = File(context.filesDir, "downloads")
|
||||
if (!downloadDirectory.exists()) {
|
||||
downloadDirectory.mkdirs()
|
||||
}
|
||||
|
||||
downloadCache = SimpleCache(
|
||||
downloadDirectory,
|
||||
NoOpCacheEvictor(),
|
||||
DefaultHttpDataSource.Factory()
|
||||
)
|
||||
|
||||
// Initialize download manager
|
||||
downloadManager = DownloadManager(
|
||||
context,
|
||||
createDatabaseProvider(),
|
||||
downloadCache,
|
||||
DefaultHttpDataSource.Factory(),
|
||||
null
|
||||
)
|
||||
|
||||
// Start tracking downloads
|
||||
downloadManager.addListener(object : DownloadManager.Listener {
|
||||
override fun onDownloadChanged(
|
||||
downloadManager: DownloadManager,
|
||||
download: Download,
|
||||
finalException: Exception?
|
||||
) {
|
||||
val downloadInfo = activeDownloads[download.request.id] ?: return
|
||||
|
||||
when (download.state) {
|
||||
Download.STATE_DOWNLOADING -> {
|
||||
sendEvent("onProgress", mapOf(
|
||||
"id" to download.request.id,
|
||||
"progress" to (download.percentDownloaded / 100.0),
|
||||
"state" to "DOWNLOADING",
|
||||
"metadata" to downloadInfo.metadata,
|
||||
"startTime" to downloadInfo.startTime
|
||||
))
|
||||
}
|
||||
Download.STATE_COMPLETED -> {
|
||||
handleCompletedDownload(download, downloadInfo)
|
||||
}
|
||||
Download.STATE_FAILED -> {
|
||||
handleFailedDownload(download, downloadInfo, finalException)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun downloadHLSAsset(providedId: String, url: String, metadata: Map<String, Any>?) {
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
// Check if download already exists
|
||||
val downloadDir = File(context.filesDir, "downloads/$providedId")
|
||||
if (downloadDir.exists() && downloadDir.list()?.any { it.endsWith(".m3u8") } == true) {
|
||||
sendEvent("onComplete", mapOf(
|
||||
"id" to providedId,
|
||||
"location" to downloadDir.absolutePath,
|
||||
"state" to "DONE",
|
||||
"metadata" to (metadata ?: emptyMap()),
|
||||
"startTime" to startTime
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val mediaItem = MediaItem.fromUri(Uri.parse(url))
|
||||
val downloadRequest = DownloadRequest.Builder(providedId, mediaItem.mediaId)
|
||||
.setCustomCacheKey(providedId)
|
||||
.setData(metadata?.toString()?.toByteArray() ?: ByteArray(0))
|
||||
.build()
|
||||
|
||||
activeDownloads[providedId] = DownloadInfo(
|
||||
metadata = metadata ?: emptyMap(),
|
||||
startTime = startTime,
|
||||
downloadRequest = downloadRequest
|
||||
)
|
||||
|
||||
downloadManager.addDownload(downloadRequest)
|
||||
|
||||
sendEvent("onProgress", mapOf(
|
||||
"id" to providedId,
|
||||
"progress" to 0.0,
|
||||
"state" to "PENDING",
|
||||
"metadata" to (metadata ?: emptyMap()),
|
||||
"startTime" to startTime
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
sendEvent("onError", mapOf(
|
||||
"id" to providedId,
|
||||
"error" to e.localizedMessage,
|
||||
"state" to "FAILED",
|
||||
"metadata" to (metadata ?: emptyMap()),
|
||||
"startTime" to startTime
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelDownload(providedId: String) {
|
||||
val downloadInfo = activeDownloads[providedId] ?: return
|
||||
downloadInfo.downloadRequest?.let { request ->
|
||||
downloadManager.removeDownload(request.id)
|
||||
sendEvent("onError", mapOf(
|
||||
"id" to providedId,
|
||||
"error" to "Download cancelled",
|
||||
"state" to "CANCELLED",
|
||||
"metadata" to downloadInfo.metadata,
|
||||
"startTime" to downloadInfo.startTime
|
||||
))
|
||||
activeDownloads.remove(providedId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCompletedDownload(download: Download, downloadInfo: DownloadInfo) {
|
||||
try {
|
||||
val downloadDir = File(context.filesDir, "downloads/${download.request.id}")
|
||||
if (!downloadDir.exists()) {
|
||||
downloadDir.mkdirs()
|
||||
}
|
||||
|
||||
// Save metadata if present
|
||||
downloadInfo.metadata.takeIf { it.isNotEmpty() }?.let { metadata ->
|
||||
val metadataFile = File(downloadDir, "${download.request.id}.json")
|
||||
metadataFile.writeText(JSONObject(metadata).toString())
|
||||
}
|
||||
|
||||
sendEvent("onComplete", mapOf(
|
||||
"id" to download.request.id,
|
||||
"location" to downloadDir.absolutePath,
|
||||
"state" to "DONE",
|
||||
"metadata" to downloadInfo.metadata,
|
||||
"startTime" to downloadInfo.startTime
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
handleFailedDownload(download, downloadInfo, e)
|
||||
} finally {
|
||||
activeDownloads.remove(download.request.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFailedDownload(
|
||||
download: Download,
|
||||
downloadInfo: DownloadInfo,
|
||||
error: Exception?
|
||||
) {
|
||||
sendEvent("onError", mapOf(
|
||||
"id" to download.request.id,
|
||||
"error" to (error?.localizedMessage ?: "Unknown error"),
|
||||
"state" to "FAILED",
|
||||
"metadata" to downloadInfo.metadata,
|
||||
"startTime" to downloadInfo.startTime
|
||||
))
|
||||
activeDownloads.remove(download.request.id)
|
||||
}
|
||||
|
||||
private fun createDatabaseProvider() = StandaloneDatabaseProvider(context)
|
||||
|
||||
private fun sendEvent(eventName: String, params: Map<String, Any>) {
|
||||
mainHandler.post {
|
||||
context
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit(eventName, params)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DOWNLOAD_CONTENT_DIRECTORY = "downloads"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import AVFoundation
|
||||
import ExpoModulesCore
|
||||
|
||||
// Separate delegate class for managing download-specific state
|
||||
class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
|
||||
weak var module: HlsDownloaderModule?
|
||||
var taskIdentifier: Int = 0
|
||||
@@ -39,16 +40,21 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
|
||||
}
|
||||
|
||||
public class HlsDownloaderModule: Module {
|
||||
private lazy var delegateHandler: HlsDownloaderDelegate = {
|
||||
return HlsDownloaderDelegate(module: self)
|
||||
// Main delegate handler for the download session
|
||||
private lazy var delegateHandler: HLSDownloadDelegate = {
|
||||
return HLSDownloadDelegate(module: self)
|
||||
}()
|
||||
|
||||
// Track active downloads with all necessary information
|
||||
var activeDownloads:
|
||||
[Int: (
|
||||
task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any],
|
||||
task: AVAssetDownloadTask,
|
||||
delegate: HLSDownloadDelegate,
|
||||
metadata: [String: Any],
|
||||
startTime: Double
|
||||
)] = [:]
|
||||
|
||||
// Configure background download session
|
||||
private lazy var downloadSession: AVAssetDownloadURLSession = {
|
||||
let configuration = URLSessionConfiguration.background(
|
||||
withIdentifier: "com.example.hlsdownload")
|
||||
@@ -70,11 +76,15 @@ public class HlsDownloaderModule: Module {
|
||||
Function("downloadHLSAsset") {
|
||||
(providedId: String, url: String, metadata: [String: Any]?) -> Void in
|
||||
let startTime = Date().timeIntervalSince1970
|
||||
|
||||
// Check if download already exists
|
||||
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 download exists and is valid, return immediately
|
||||
if fm.fileExists(atPath: potentialExistingLocation.path) {
|
||||
if let files = try? fm.contentsOfDirectory(atPath: potentialExistingLocation.path),
|
||||
files.contains(where: { $0.hasSuffix(".m3u8") })
|
||||
@@ -94,6 +104,7 @@ public class HlsDownloaderModule: Module {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
guard let assetURL = URL(string: url) else {
|
||||
self.sendEvent(
|
||||
"onError",
|
||||
@@ -107,14 +118,16 @@ public class HlsDownloaderModule: Module {
|
||||
return
|
||||
}
|
||||
|
||||
// Configure asset with necessary options
|
||||
let asset = AVURLAsset(
|
||||
url: assetURL,
|
||||
options: [
|
||||
"AVURLAssetOutOfBandMIMETypeKey": "application/x-mpegURL",
|
||||
"AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "Streamyfin/1.0"],
|
||||
"AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "MyApp/1.0"],
|
||||
"AVURLAssetAllowsCellularAccessKey": true,
|
||||
])
|
||||
|
||||
// Load asset asynchronously
|
||||
asset.loadValuesAsynchronously(forKeys: ["playable", "duration"]) {
|
||||
var error: NSError?
|
||||
let status = asset.statusOfValue(forKey: "playable", error: &error)
|
||||
@@ -134,6 +147,7 @@ public class HlsDownloaderModule: Module {
|
||||
return
|
||||
}
|
||||
|
||||
// Create download task with quality options
|
||||
guard
|
||||
let task = self.downloadSession.makeAssetDownloadTask(
|
||||
asset: asset,
|
||||
@@ -159,13 +173,16 @@ public class HlsDownloaderModule: Module {
|
||||
return
|
||||
}
|
||||
|
||||
// Configure delegate for this download
|
||||
let delegate = HLSDownloadDelegate(module: self)
|
||||
delegate.providedId = providedId
|
||||
delegate.startTime = startTime
|
||||
delegate.taskIdentifier = task.taskIdentifier
|
||||
|
||||
// Store download information
|
||||
self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:], startTime)
|
||||
|
||||
// Send initial progress event
|
||||
self.sendEvent(
|
||||
"onProgress",
|
||||
[
|
||||
@@ -176,19 +193,21 @@ public class HlsDownloaderModule: Module {
|
||||
"startTime": startTime,
|
||||
])
|
||||
|
||||
// Start the download
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional methods and event handlers...
|
||||
Function("cancelDownload") { (providedId: String) -> Void in
|
||||
guard
|
||||
let entry = self.activeDownloads.first(where: { $0.value.delegate.providedId == providedId }
|
||||
)
|
||||
else {
|
||||
print("No active download found with identifier: \(providedId)")
|
||||
return
|
||||
}
|
||||
|
||||
let (task, _, metadata, startTime) = entry.value
|
||||
|
||||
self.sendEvent(
|
||||
@@ -203,13 +222,10 @@ public class HlsDownloaderModule: Module {
|
||||
|
||||
task.cancel()
|
||||
self.activeDownloads.removeValue(forKey: task.taskIdentifier)
|
||||
print("Download cancelled for identifier: \(providedId)")
|
||||
}
|
||||
|
||||
OnStartObserving {}
|
||||
OnStopObserving {}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
func removeDownload(with id: Int) {
|
||||
activeDownloads.removeValue(forKey: id)
|
||||
}
|
||||
@@ -218,9 +234,11 @@ public class HlsDownloaderModule: Module {
|
||||
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)
|
||||
|
||||
@@ -238,55 +256,9 @@ public class HlsDownloaderModule: Module {
|
||||
|
||||
return newLocation
|
||||
}
|
||||
|
||||
func mappedState(for task: URLSessionTask, errorOccurred: Bool = false) -> String {
|
||||
if errorOccurred { return "FAILED" }
|
||||
switch task.state {
|
||||
case .running: return "DOWNLOADING"
|
||||
case .suspended: return "PAUSED"
|
||||
case .completed: return "DONE"
|
||||
case .canceling: return "STOPPED"
|
||||
@unknown default: return "PENDING"
|
||||
}
|
||||
}
|
||||
}
|
||||
class HlsDownloaderDelegate: 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Extension for URL session delegate methods
|
||||
extension HlsDownloaderModule {
|
||||
func urlSession(
|
||||
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange,
|
||||
@@ -324,41 +296,27 @@ extension HlsDownloaderModule {
|
||||
|
||||
do {
|
||||
let newLocation = try persistDownloadedFolder(
|
||||
originalLocation: location, folderName: downloadInfo.delegate.providedId)
|
||||
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)
|
||||
withJSONObject: downloadInfo.metadata,
|
||||
options: .prettyPrinted)
|
||||
try jsonData.write(to: metadataLocation)
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await rewriteM3U8Files(baseDir: newLocation.path)
|
||||
|
||||
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,
|
||||
])
|
||||
}
|
||||
}
|
||||
sendEvent(
|
||||
"onComplete",
|
||||
[
|
||||
"id": downloadInfo.delegate.providedId,
|
||||
"location": newLocation.absoluteString,
|
||||
"state": "DONE",
|
||||
"metadata": downloadInfo.metadata,
|
||||
"startTime": downloadInfo.startTime,
|
||||
])
|
||||
} catch {
|
||||
sendEvent(
|
||||
"onError",
|
||||
@@ -374,9 +332,7 @@ extension HlsDownloaderModule {
|
||||
removeDownload(with: assetDownloadTask.taskIdentifier)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?
|
||||
) {
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
guard let error = error,
|
||||
let downloadInfo = activeDownloads[task.taskIdentifier]
|
||||
else { return }
|
||||
|
||||
Reference in New Issue
Block a user