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.
- package/CHANGELOG.md +54 -1
- package/README.md +422 -267
- package/android/src/main/AndroidManifest.xml +20 -1
- package/android/src/main/java/expo/modules/ytdlp/ExpoYtDlpModule.kt +8 -0
- package/android/src/main/java/expo/modules/ytdlp/YtDlpDownloadManager.kt +177 -16
- package/android/src/main/java/expo/modules/ytdlp/YtDlpEngine.kt +35 -4
- package/android/src/main/java/expo/modules/ytdlp/YtDlpForegroundService.kt +241 -0
- package/android/src/main/java/expo/modules/ytdlp/YtDlpTask.kt +44 -2
- package/build/ExpoYtDlpModule.d.ts +2 -0
- package/build/ExpoYtDlpModule.d.ts.map +1 -1
- package/build/ExpoYtDlpModule.js.map +1 -1
- package/build/YtDlp.d.ts +10 -0
- package/build/YtDlp.d.ts.map +1 -1
- package/build/YtDlp.js +22 -77
- package/build/YtDlp.js.map +1 -1
- package/build/constants.d.ts +1 -4
- package/build/constants.d.ts.map +1 -1
- package/build/constants.js +2 -1
- package/build/constants.js.map +1 -1
- package/build/downloadTask.d.ts +2 -0
- package/build/downloadTask.d.ts.map +1 -1
- package/build/downloadTask.js +7 -0
- package/build/downloadTask.js.map +1 -1
- package/build/errors.js +3 -3
- package/build/errors.js.map +1 -1
- package/build/index.d.ts +3 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -1
- package/build/index.js.map +1 -1
- package/build/mappers.js +3 -2
- package/build/mappers.js.map +1 -1
- package/build/serializers.d.ts +5 -0
- package/build/serializers.d.ts.map +1 -0
- package/build/serializers.js +96 -0
- package/build/serializers.js.map +1 -0
- package/build/types.d.ts +39 -1
- package/build/types.d.ts.map +1 -1
- package/build/types.js.map +1 -1
- package/package.json +7 -5
- package/src/ExpoYtDlpModule.ts +2 -0
- package/src/YtDlp.ts +21 -57
- package/src/__tests__/errors.test.ts +117 -0
- package/src/__tests__/mappers.test.ts +167 -0
- package/src/__tests__/serializers.test.ts +72 -0
- package/src/constants.ts +3 -1
- package/src/downloadTask.ts +9 -0
- package/src/errors.ts +3 -3
- package/src/index.ts +3 -1
- package/src/mappers.ts +3 -2
- package/src/serializers.ts +78 -0
- 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 =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
task.
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|