ytdlp-react-native 1.0.0 → 1.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.md +422 -267
  3. package/android/src/main/AndroidManifest.xml +20 -1
  4. package/android/src/main/java/expo/modules/ytdlp/ExpoYtDlpModule.kt +8 -0
  5. package/android/src/main/java/expo/modules/ytdlp/YtDlpDownloadManager.kt +177 -16
  6. package/android/src/main/java/expo/modules/ytdlp/YtDlpEngine.kt +35 -4
  7. package/android/src/main/java/expo/modules/ytdlp/YtDlpForegroundService.kt +241 -0
  8. package/android/src/main/java/expo/modules/ytdlp/YtDlpTask.kt +44 -2
  9. package/build/ExpoYtDlpModule.d.ts +2 -0
  10. package/build/ExpoYtDlpModule.d.ts.map +1 -1
  11. package/build/ExpoYtDlpModule.js.map +1 -1
  12. package/build/YtDlp.d.ts +10 -0
  13. package/build/YtDlp.d.ts.map +1 -1
  14. package/build/YtDlp.js +22 -77
  15. package/build/YtDlp.js.map +1 -1
  16. package/build/constants.d.ts +1 -4
  17. package/build/constants.d.ts.map +1 -1
  18. package/build/constants.js +2 -1
  19. package/build/constants.js.map +1 -1
  20. package/build/downloadTask.d.ts +2 -0
  21. package/build/downloadTask.d.ts.map +1 -1
  22. package/build/downloadTask.js +7 -0
  23. package/build/downloadTask.js.map +1 -1
  24. package/build/errors.js +3 -3
  25. package/build/errors.js.map +1 -1
  26. package/build/index.d.ts +3 -1
  27. package/build/index.d.ts.map +1 -1
  28. package/build/index.js +3 -1
  29. package/build/index.js.map +1 -1
  30. package/build/mappers.js +3 -2
  31. package/build/mappers.js.map +1 -1
  32. package/build/serializers.d.ts +5 -0
  33. package/build/serializers.d.ts.map +1 -0
  34. package/build/serializers.js +96 -0
  35. package/build/serializers.js.map +1 -0
  36. package/build/types.d.ts +39 -1
  37. package/build/types.d.ts.map +1 -1
  38. package/build/types.js.map +1 -1
  39. package/package.json +7 -5
  40. package/src/ExpoYtDlpModule.ts +2 -0
  41. package/src/YtDlp.ts +21 -57
  42. package/src/__tests__/errors.test.ts +117 -0
  43. package/src/__tests__/mappers.test.ts +167 -0
  44. package/src/__tests__/serializers.test.ts +72 -0
  45. package/src/constants.ts +3 -1
  46. package/src/downloadTask.ts +9 -0
  47. package/src/errors.ts +3 -3
  48. package/src/index.ts +3 -1
  49. package/src/mappers.ts +3 -2
  50. package/src/serializers.ts +78 -0
  51. package/src/types.ts +47 -1
@@ -1,2 +1,21 @@
1
- <manifest>
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <!-- Foreground-service promotion for active downloads (issue #3).
3
+ Normal permissions: granted automatically at install time. -->
4
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
5
+ <!-- Required on API 34+ for the dataSync foreground-service type. -->
6
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
7
+ <!-- Partial wake lock keeps screen-off downloads moving. -->
8
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
9
+
10
+ <!-- Note: POST_NOTIFICATIONS is intentionally *not* declared here. It is a
11
+ dangerous permission that the host app must request at runtime on
12
+ API 33+ if it wants the progress notification to be visible. The
13
+ service still runs without it; the notification is then suppressed. -->
14
+
15
+ <application>
16
+ <service
17
+ android:name="expo.modules.ytdlp.YtDlpForegroundService"
18
+ android:exported="false"
19
+ android:foregroundServiceType="dataSync" />
20
+ </application>
2
21
  </manifest>
@@ -47,6 +47,14 @@ class ExpoYtDlpModule : Module() {
47
47
  manager().cancel(taskId)
48
48
  }
49
49
 
50
+ Function("pauseDownload") { taskId: String ->
51
+ manager().pause(taskId)
52
+ }
53
+
54
+ Function("resumeDownload") { taskId: String ->
55
+ manager().resume(taskId)
56
+ }
57
+
50
58
  Function("getDownloadStatus") { taskId: String ->
51
59
  manager().statusOf(taskId)
52
60
  }
@@ -11,16 +11,39 @@ import java.util.concurrent.Executors
11
11
  * Owns the concurrent download task registry (AGENTS.md §21) and runs each
12
12
  * task on its own background thread so the module call thread is never
13
13
  * blocked for the duration of a download (AGENTS.md §39).
14
+ *
15
+ * Pause/resume (issue #4): `pause()` asks yt-dlp to abort via the progress
16
+ * hook; the `.part` file stays on disk and the task stays registered with
17
+ * status `PAUSED`. `resume()` re-runs the exact same download and yt-dlp
18
+ * continues from the partial file by default (`--continue`), so no extra
19
+ * options are needed. a [runGeneration] counter per task ensures a stale
20
+ * runner thread (the one that just paused) can never overwrite the fresh run.
21
+ *
22
+ * Background execution (issue #3, AGENTS.md §38): while at least one run is
23
+ * active the manager promotes the process with [YtDlpForegroundService]
24
+ * (type `dataSync`) and feeds it progress for the ongoing notification. When
25
+ * the last run ends the service is stopped again.
14
26
  */
15
27
  internal class YtDlpDownloadManager(
16
28
  private val context: Context,
17
29
  private val onEvent: (Map<String, Any>) -> Unit,
18
30
  ) {
19
31
 
32
+ private val appContext: Context = context.applicationContext
20
33
  private val tasks = ConcurrentHashMap<String, YtDlpTask>()
34
+ private val resumeOptions = ConcurrentHashMap<String, Map<String, Any?>>()
21
35
  private val executor: ExecutorService = Executors.newCachedThreadPool()
22
36
 
37
+ /** Runs currently executing on a background thread. Guarded by `this`. */
38
+ @Volatile
39
+ private var activeRuns = 0
40
+
41
+ init {
42
+ YtDlpForegroundService.setManager(this)
43
+ }
44
+
23
45
  /** Creates, registers and starts a download. Returns the public task info. */
46
+ @Synchronized
24
47
  fun start(options: Map<String, Any?>): Map<String, Any> {
25
48
  YtDlpEngine.ensureInitialized(context)
26
49
 
@@ -35,21 +58,75 @@ internal class YtDlpDownloadManager(
35
58
  val task = YtDlpTask(
36
59
  id = UUID.randomUUID().toString(),
37
60
  outputDirectory = outputDirectory,
38
- onEvent = onEvent,
61
+ onEvent = ::emit,
39
62
  )
40
63
 
64
+ val generation = task.beginRun()
41
65
  tasks[task.id] = task
66
+ resumeOptions[task.id] = options
42
67
  task.setStatus(YtDlpStatus.EXTRACTING)
43
68
  task.emitState()
44
- executor.execute { runTask(task, options, outputDirectory) }
69
+ executor.execute { runTask(task, outputDirectory, generation) }
70
+ runStarted()
45
71
  return mapOf("taskId" to task.id, "directory" to outputDirectory.absolutePath)
46
72
  }
47
73
 
74
+ /**
75
+ * Pause an in-flight download. Returns `false` when the task is unknown,
76
+ * already paused, or already in a terminal state.
77
+ */
78
+ @Synchronized
79
+ fun pause(taskId: String): Boolean {
80
+ val task = tasks[taskId] ?: return false
81
+ return task.requestPause()
82
+ }
83
+
84
+ /**
85
+ * Resume a paused download. Returns `false` when the task is unknown or not
86
+ * in the `PAUSED` state.
87
+ */
88
+ @Synchronized
89
+ fun resume(taskId: String): Boolean {
90
+ val task = tasks[taskId] ?: return false
91
+ if (task.status != YtDlpStatus.PAUSED) return false
92
+ val options = resumeOptions[taskId] ?: return false
93
+ val generation = task.beginRun()
94
+ task.setStatus(YtDlpStatus.EXTRACTING)
95
+ task.emitState()
96
+ executor.execute { runTask(task, task.outputDirectory, generation) }
97
+ runStarted()
98
+ return true
99
+ }
100
+
101
+ /**
102
+ * Cancel a download. A paused task is finalized immediately (there is no
103
+ * runner alive to observe the cancel request).
104
+ */
105
+ @Synchronized
48
106
  fun cancel(taskId: String): Boolean {
49
107
  val task = tasks[taskId] ?: return false
108
+ if (task.status == YtDlpStatus.PAUSED) {
109
+ task.emitCancelled()
110
+ releaseTask(task.id)
111
+ runFinished()
112
+ return true
113
+ }
50
114
  return task.requestCancel()
51
115
  }
52
116
 
117
+ /**
118
+ * Cancel every registered download (notification Cancel action). Returns
119
+ * how many tasks were asked to stop.
120
+ */
121
+ @Synchronized
122
+ fun cancelAll(): Int {
123
+ var cancelled = 0
124
+ for (taskId in tasks.keys.toList()) {
125
+ if (cancel(taskId)) cancelled++
126
+ }
127
+ return cancelled
128
+ }
129
+
53
130
  fun statusOf(taskId: String): Map<String, Any?>? {
54
131
  val task = tasks[taskId] ?: return null
55
132
  return mapOf<String, Any?>(
@@ -66,32 +143,116 @@ internal class YtDlpDownloadManager(
66
143
 
67
144
  fun shutdown() {
68
145
  executor.shutdownNow()
146
+ synchronized(this) {
147
+ activeRuns = 0
148
+ tasks.clear()
149
+ resumeOptions.clear()
150
+ }
151
+ YtDlpForegroundService.setManager(null)
152
+ YtDlpForegroundService.stop(appContext)
69
153
  }
70
154
 
71
- private fun runTask(task: YtDlpTask, options: Map<String, Any?>, outputDirectory: File) {
155
+ /** Forwards native events to JS and mirrors progress into the notification. */
156
+ private fun emit(payload: Map<String, Any>) {
157
+ updateServiceNotification(payload)
158
+ onEvent(payload)
159
+ }
160
+
161
+ private fun updateServiceNotification(payload: Map<String, Any>) {
162
+ when (payload["type"]) {
163
+ "progress" -> {
164
+ val progress = payload["progress"] as? Map<*, *> ?: return
165
+ val percent = (progress["percent"] as? Number)?.toDouble()
166
+ val filename = (progress["filename"] as? String)?.substringAfterLast('/')
167
+ YtDlpForegroundService.update(activeRuns, percent, filename)
168
+ }
169
+ "state" -> {
170
+ if ((payload["status"] as? String) == "extracting") {
171
+ YtDlpForegroundService.update(activeRuns, null, "Extracting info")
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ private fun runStarted() {
178
+ activeRuns += 1
179
+ YtDlpForegroundService.start(appContext, activeRuns)
180
+ }
181
+
182
+ private fun runFinished() {
183
+ if (activeRuns > 0) activeRuns -= 1
184
+ if (activeRuns == 0) {
185
+ YtDlpForegroundService.stop(appContext)
186
+ } else {
187
+ YtDlpForegroundService.update(activeRuns, null, null)
188
+ }
189
+ }
190
+
191
+ private fun runTask(task: YtDlpTask, outputDirectory: File, generation: Int) {
192
+ val options = resumeOptions[task.id]
193
+ if (options == null) {
194
+ synchronized(this) {
195
+ if (task.runGeneration == generation) releaseTask(task.id)
196
+ runFinished()
197
+ }
198
+ return
199
+ }
72
200
  try {
73
201
  YtDlpEngine.executeDownload(task, options, outputDirectory)
202
+ completeRun(task, generation, outputDirectory)
203
+ } catch (e: YtDlpNativeException) {
204
+ failRun(task, generation, e.errorCode, e.message ?: "Download failed")
205
+ } catch (e: Throwable) {
206
+ failRun(task, generation, "DOWNLOAD_FAILED", e.message ?: "Download failed")
207
+ }
208
+ }
209
+
210
+ private fun completeRun(task: YtDlpTask, generation: Int, outputDirectory: File) {
211
+ synchronized(this) {
212
+ if (task.runGeneration != generation) {
213
+ runFinished()
214
+ return
215
+ }
74
216
  val file = YtDlpFileUtil.findNewestFile(outputDirectory, task.startTime)
75
217
  val secured = file?.let { YtDlpFileUtil.ensureContained(it, outputDirectory) }
76
218
  task.emitCompleted(secured)
77
- } catch (e: YtDlpNativeException) {
78
- if (task.isCancelRequested() || e.errorCode == "CANCELLED") {
79
- task.emitCancelled()
80
- } else {
81
- task.emitError(e.errorCode, e.message ?: "Download failed")
219
+ releaseTask(task.id)
220
+ runFinished()
221
+ }
222
+ }
223
+
224
+ private fun failRun(task: YtDlpTask, generation: Int, code: String, message: String) {
225
+ synchronized(this) {
226
+ if (task.runGeneration != generation) {
227
+ runFinished()
228
+ return
82
229
  }
83
- } catch (e: Throwable) {
84
- if (task.isCancelRequested()) {
85
- task.emitCancelled()
86
- } else {
87
- task.emitError("DOWNLOAD_FAILED", e.message ?: "Download failed")
230
+ when {
231
+ // Cancel has priority over pause when both were requested.
232
+ task.isCancelRequested() || code == "CANCELLED" -> {
233
+ task.emitCancelled()
234
+ releaseTask(task.id)
235
+ }
236
+ task.isPauseRequested() || code == "PAUSED" -> {
237
+ // Keep the task registered so it can be resumed; the `.part` file
238
+ // stays on disk and yt-dlp continues from it on the next run.
239
+ task.emitPaused()
240
+ }
241
+ else -> {
242
+ task.emitError(code, message)
243
+ releaseTask(task.id)
244
+ }
88
245
  }
89
- } finally {
90
- tasks.remove(task.id)
246
+ runFinished()
91
247
  }
92
248
  }
93
249
 
250
+ private fun releaseTask(taskId: String) {
251
+ tasks.remove(taskId)
252
+ resumeOptions.remove(taskId)
253
+ }
254
+
94
255
  private companion object {
95
256
  const val SUB_DIRECTORY = "yt-dlp"
96
257
  }
97
- }
258
+ }
@@ -115,6 +115,9 @@ internal object YtDlpEngine {
115
115
  if (task.isCancelRequested() || message.contains(CANCEL_SENTINEL)) {
116
116
  throw YtDlpNativeException("CANCELLED", "Download cancelled.")
117
117
  }
118
+ if (task.isPauseRequested() || message.contains(PAUSE_SENTINEL)) {
119
+ throw YtDlpNativeException("PAUSED", "Download paused.")
120
+ }
118
121
  throw YtDlpNativeException(classifyDownloadFailure(message), message, e)
119
122
  } catch (e: Exception) {
120
123
  throw YtDlpNativeException("DOWNLOAD_FAILED", "Download failed: ${e.message}", e)
@@ -133,6 +136,18 @@ internal object YtDlpEngine {
133
136
 
134
137
  (options["referer"] as? String)?.takeIf { it.isNotBlank() }?.let { opts["http_referer"] = it }
135
138
 
139
+ ffmpegLocationOf(options)?.let { location ->
140
+ if (!File(location).exists()) {
141
+ throw YtDlpNativeException(
142
+ "PROCESSING_FAILED",
143
+ "The FFmpeg binary at \"$location\" does not exist. Provide the path to an ffmpeg executable or a directory containing it.",
144
+ )
145
+ }
146
+ // yt-dlp accepts either the binary path or its containing directory, so
147
+ // the caller's value is passed through unchanged.
148
+ opts["ffmpeg_location"] = location
149
+ }
150
+
136
151
  val playlist = options["playlist"] as? Map<*, *>
137
152
  if (playlist?.get("enabled") != true) opts["noplaylist"] = true
138
153
  (playlist?.get("start") as? Number)?.takeIf { it.toInt() > 0 }?.let { opts["playliststart"] = it.toInt() }
@@ -153,29 +168,42 @@ internal object YtDlpEngine {
153
168
  return opts
154
169
  }
155
170
 
156
- /** Anything we cannot honestly support is rejected up front (AGENTS.md §19). */
171
+ /**
172
+ * Rejects FFmpeg-dependent features up front when no FFmpeg binary is
173
+ * available (AGENTS.md §19). When the caller supplies `ffmpeg.location`,
174
+ * these features are handed to yt-dlp instead of being refused.
175
+ */
157
176
  private fun rejectUnsupportedFeatures(options: Map<String, Any?>) {
177
+ if (ffmpegLocationOf(options) != null) return
158
178
  if (options["merge"] == true) {
159
179
  throw YtDlpNativeException(
160
180
  "PROCESSING_FAILED",
161
- "Merging video and audio requires FFmpeg, which is not bundled with yt-dlp-android.",
181
+ "Merging video and audio requires FFmpeg, which is not bundled. " +
182
+ "Provide an ffmpeg executable via the `ffmpeg.location` download option.",
162
183
  )
163
184
  }
164
185
  val audio = options["audio"] as? Map<*, *>
165
186
  if (audio?.get("only") == true || audio?.get("format") != null || audio?.get("quality") != null) {
166
187
  throw YtDlpNativeException(
167
188
  "PROCESSING_FAILED",
168
- "Audio extraction and re-encoding require FFmpeg, which is not bundled with yt-dlp-android.",
189
+ "Audio extraction and re-encoding require FFmpeg. " +
190
+ "Provide an ffmpeg executable via the `ffmpeg.location` download option.",
169
191
  )
170
192
  }
171
193
  if (options["metadata"] != null || options["thumbnail"] != null) {
172
194
  throw YtDlpNativeException(
173
195
  "PROCESSING_FAILED",
174
- "Metadata and thumbnail embedding are not supported in this version.",
196
+ "Metadata and thumbnail embedding require FFmpeg. " +
197
+ "Provide an ffmpeg executable via the `ffmpeg.location` download option.",
175
198
  )
176
199
  }
177
200
  }
178
201
 
202
+ private fun ffmpegLocationOf(options: Map<String, Any?>): String? {
203
+ val ffmpeg = options["ffmpeg"] as? Map<*, *>
204
+ return (ffmpeg?.get("location") as? String)?.trim()?.takeIf { it.isNotEmpty() }
205
+ }
206
+
179
207
  private fun classifyDownloadFailure(message: String): String {
180
208
  val haystack = message.lowercase()
181
209
  return when {
@@ -248,6 +276,7 @@ internal object YtDlpEngine {
248
276
  }
249
277
 
250
278
  private const val CANCEL_SENTINEL = "YTDLP_CANCELLED"
279
+ private const val PAUSE_SENTINEL = "YTDLP_PAUSED"
251
280
 
252
281
  /**
253
282
  * Drive the bundled yt-dlp directly. Importing is deferred so our first
@@ -258,6 +287,8 @@ private const val CANCEL_SENTINEL = "YTDLP_CANCELLED"
258
287
  def _expo_ytdlp_hook(task, d):
259
288
  if task.isCancelRequested():
260
289
  raise RuntimeError("YTDLP_CANCELLED")
290
+ if task.isPauseRequested():
291
+ raise RuntimeError("YTDLP_PAUSED")
261
292
  downloaded = int(d.get('downloaded_bytes') or 0)
262
293
  total = int(d.get('total_bytes') or d.get('total_bytes_estimate') or 0)
263
294
  speed = int(d.get('speed') or 0)
@@ -0,0 +1,241 @@
1
+ package expo.modules.ytdlp
2
+
3
+ import android.app.Notification
4
+ import android.app.NotificationChannel
5
+ import android.app.NotificationManager
6
+ import android.app.PendingIntent
7
+ import android.app.Service
8
+ import android.content.Context
9
+ import android.content.Intent
10
+ import android.content.pm.ServiceInfo
11
+ import android.os.Build
12
+ import android.os.IBinder
13
+ import android.os.PowerManager
14
+ import java.lang.ref.WeakReference
15
+
16
+ /**
17
+ * Foreground service that keeps the process alive while downloads are active
18
+ * (issue #3, AGENTS.md §38).
19
+ *
20
+ * Architecture: `ExpoYtDlpModule` → [YtDlpDownloadManager] → this service →
21
+ * yt-dlp. The manager starts the service when the first run begins and stops
22
+ * it when the last run ends, so the notification is only visible while there
23
+ * is real work. The service itself never downloads; it only promotes the
24
+ * process to foreground, shows progress, and forwards the notification's
25
+ * Cancel action to the manager.
26
+ *
27
+ * Only framework APIs are used (no new Gradle dependencies). All
28
+ * version-gated calls are branched on [Build.VERSION.SDK_INT]; the module's
29
+ * `minSdk` is 24.
30
+ *
31
+ * Honest limits (documented in the README): the service keeps a *running*
32
+ * process alive when the app is backgrounded or the screen is off, but it
33
+ * cannot resurrect downloads after the process is killed or the device
34
+ * reboots — tasks are process-local by design.
35
+ */
36
+ internal class YtDlpForegroundService : Service() {
37
+
38
+ private var wakeLock: PowerManager.WakeLock? = null
39
+
40
+ override fun onBind(intent: Intent?): IBinder? = null
41
+
42
+ override fun onCreate() {
43
+ super.onCreate()
44
+ instance = WeakReference(this)
45
+ // Keep the CPU awake during screen-off downloads; released in onDestroy,
46
+ // and dies with the process if the system kills us.
47
+ val powerManager = getSystemService(POWER_SERVICE) as PowerManager
48
+ wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).also {
49
+ it.acquire()
50
+ }
51
+ }
52
+
53
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
54
+ if (intent?.action == ACTION_CANCEL_ALL) {
55
+ manager()?.cancelAll()
56
+ return START_NOT_STICKY
57
+ }
58
+ ensureChannel()
59
+ promote(buildNotification())
60
+ return START_NOT_STICKY
61
+ }
62
+
63
+ override fun onDestroy() {
64
+ try {
65
+ wakeLock?.let { if (it.isHeld) it.release() }
66
+ } catch (_: Throwable) {
67
+ // Best effort; the lock dies with the process regardless.
68
+ }
69
+ wakeLock = null
70
+ if (instance?.get() === this) instance = null
71
+ super.onDestroy()
72
+ }
73
+
74
+ /** Rebuilds the notification from the latest snapshot (no-op pre-promotion). */
75
+ private fun refresh() {
76
+ try {
77
+ notificationManager().notify(NOTIFICATION_ID, buildNotification())
78
+ } catch (_: Throwable) {
79
+ // Notification updates are best effort.
80
+ }
81
+ }
82
+
83
+ private fun promote(notification: Notification) {
84
+ if (Build.VERSION.SDK_INT >= 29) {
85
+ startForeground(
86
+ NOTIFICATION_ID,
87
+ notification,
88
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
89
+ )
90
+ } else {
91
+ @Suppress("DEPRECATION")
92
+ startForeground(NOTIFICATION_ID, notification)
93
+ }
94
+ }
95
+
96
+ private fun buildNotification(): Notification {
97
+ val (title, text, percent) = content(activeCount, lastPercent, lastTitle)
98
+ val cancelIntent = PendingIntent.getService(
99
+ this,
100
+ REQUEST_CANCEL,
101
+ Intent(this, YtDlpForegroundService::class.java).setAction(ACTION_CANCEL_ALL),
102
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
103
+ )
104
+ val builder = if (Build.VERSION.SDK_INT >= 26) {
105
+ Notification.Builder(this, CHANNEL_ID)
106
+ } else {
107
+ @Suppress("DEPRECATION")
108
+ Notification.Builder(this)
109
+ }
110
+ builder
111
+ .setContentTitle(title)
112
+ .setContentText(text)
113
+ .setSmallIcon(android.R.drawable.stat_sys_download)
114
+ .setOngoing(true)
115
+ .setOnlyAlertOnce(true)
116
+ .setProgress(100, percent ?: 0, percent == null)
117
+ .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelIntent)
118
+ contentIntent()?.let { builder.setContentIntent(it) }
119
+ if (Build.VERSION.SDK_INT < 26) {
120
+ @Suppress("DEPRECATION")
121
+ builder.priority = Notification.PRIORITY_LOW
122
+ }
123
+ return builder.build()
124
+ }
125
+
126
+ /** Tapping the notification reopens the host app when possible. */
127
+ private fun contentIntent(): PendingIntent? {
128
+ val launch = packageManager.getLaunchIntentForPackage(packageName) ?: return null
129
+ return PendingIntent.getActivity(
130
+ this,
131
+ REQUEST_OPEN,
132
+ launch,
133
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
134
+ )
135
+ }
136
+
137
+ private fun ensureChannel() {
138
+ if (Build.VERSION.SDK_INT < 26) return
139
+ val manager = notificationManager()
140
+ if (manager.getNotificationChannel(CHANNEL_ID) == null) {
141
+ manager.createNotificationChannel(
142
+ NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW).apply {
143
+ description = "Shows active yt-dlp downloads."
144
+ setShowBadge(false)
145
+ },
146
+ )
147
+ }
148
+ }
149
+
150
+ private fun notificationManager(): NotificationManager {
151
+ return getSystemService(NotificationManager::class.java)
152
+ }
153
+
154
+ companion object {
155
+ const val ACTION_START = "expo.modules.ytdlp.action.START"
156
+ const val ACTION_CANCEL_ALL = "expo.modules.ytdlp.action.CANCEL_ALL"
157
+
158
+ private const val CHANNEL_ID = "ytdlp_downloads"
159
+ private const val CHANNEL_NAME = "Downloads"
160
+ private const val NOTIFICATION_ID = 1001
161
+ private const val WAKE_LOCK_TAG = "ytdlp-react-native:download"
162
+ private const val REQUEST_CANCEL = 1
163
+ private const val REQUEST_OPEN = 2
164
+
165
+ private var instance: WeakReference<YtDlpForegroundService>? = null
166
+ private var managerRef: WeakReference<YtDlpDownloadManager>? = null
167
+
168
+ @Volatile
169
+ private var activeCount = 0
170
+
171
+ @Volatile
172
+ private var lastPercent: Double? = null
173
+
174
+ @Volatile
175
+ private var lastTitle: String? = null
176
+
177
+ /** Called once by the owning [YtDlpDownloadManager]; held weakly. */
178
+ fun setManager(manager: YtDlpDownloadManager?) {
179
+ managerRef = manager?.let { WeakReference(it) }
180
+ }
181
+
182
+ private fun manager(): YtDlpDownloadManager? = managerRef?.get()
183
+
184
+ /**
185
+ * Promote the process to foreground. Best effort: when the system refuses
186
+ * (e.g. background-start restrictions on API 31+), the download still
187
+ * proceeds without the promotion.
188
+ */
189
+ fun start(context: Context, active: Int) {
190
+ activeCount = active
191
+ val intent = Intent(context.applicationContext, YtDlpForegroundService::class.java)
192
+ .setAction(ACTION_START)
193
+ try {
194
+ if (Build.VERSION.SDK_INT >= 26) {
195
+ context.applicationContext.startForegroundService(intent)
196
+ } else {
197
+ @Suppress("DEPRECATION")
198
+ context.applicationContext.startService(intent)
199
+ }
200
+ } catch (_: Throwable) {
201
+ // Best effort (see KDoc).
202
+ }
203
+ }
204
+
205
+ fun stop(context: Context) {
206
+ activeCount = 0
207
+ lastPercent = null
208
+ lastTitle = null
209
+ try {
210
+ context.applicationContext.stopService(
211
+ Intent(context.applicationContext, YtDlpForegroundService::class.java),
212
+ )
213
+ } catch (_: Throwable) {
214
+ // Best effort.
215
+ }
216
+ }
217
+
218
+ /** Refreshes the notification from the latest progress snapshot. */
219
+ fun update(active: Int, percent: Double?, title: String?) {
220
+ activeCount = active
221
+ if (percent != null) lastPercent = percent
222
+ if (!title.isNullOrBlank()) lastTitle = title
223
+ try {
224
+ instance?.get()?.refresh()
225
+ } catch (_: Throwable) {
226
+ // Notification updates are best effort.
227
+ }
228
+ }
229
+
230
+ private fun content(active: Int, percent: Double?, title: String?): Triple<String, String, Int?> {
231
+ val pct = percent?.coerceIn(0.0, 100.0)?.toInt()
232
+ return if (active > 1) {
233
+ val sub = listOfNotNull(pct?.let { "$it%" }, title)
234
+ .joinToString(" • ").ifBlank { "In progress" }
235
+ Triple("Downloading $active files", sub, pct)
236
+ } else {
237
+ Triple(title ?: "Downloading", pct?.let { "$it%" } ?: "In progress", pct)
238
+ }
239
+ }
240
+ }
241
+ }