ytdlp-react-native 1.0.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 +74 -0
- package/LICENSE +21 -0
- package/README.md +267 -0
- package/android/build.gradle +25 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/ytdlp/ExpoYtDlpModule.kt +74 -0
- package/android/src/main/java/expo/modules/ytdlp/YtDlpDownloadManager.kt +97 -0
- package/android/src/main/java/expo/modules/ytdlp/YtDlpEngine.kt +277 -0
- package/android/src/main/java/expo/modules/ytdlp/YtDlpException.kt +14 -0
- package/android/src/main/java/expo/modules/ytdlp/YtDlpFileUtil.kt +89 -0
- package/android/src/main/java/expo/modules/ytdlp/YtDlpTask.kt +225 -0
- package/build/ExpoYtDlpModule.d.ts +35 -0
- package/build/ExpoYtDlpModule.d.ts.map +1 -0
- package/build/ExpoYtDlpModule.js +10 -0
- package/build/ExpoYtDlpModule.js.map +1 -0
- package/build/ExpoYtDlpModule.web.d.ts +11 -0
- package/build/ExpoYtDlpModule.web.d.ts.map +1 -0
- package/build/ExpoYtDlpModule.web.js +13 -0
- package/build/ExpoYtDlpModule.web.js.map +1 -0
- package/build/YtDlp.d.ts +23 -0
- package/build/YtDlp.d.ts.map +1 -0
- package/build/YtDlp.js +160 -0
- package/build/YtDlp.js.map +1 -0
- package/build/constants.d.ts +9 -0
- package/build/constants.d.ts.map +1 -0
- package/build/constants.js +9 -0
- package/build/constants.js.map +1 -0
- package/build/downloadTask.d.ts +30 -0
- package/build/downloadTask.d.ts.map +1 -0
- package/build/downloadTask.js +111 -0
- package/build/downloadTask.js.map +1 -0
- package/build/errors.d.ts +27 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +111 -0
- package/build/errors.js.map +1 -0
- package/build/events.d.ts +20 -0
- package/build/events.d.ts.map +1 -0
- package/build/events.js +96 -0
- package/build/events.js.map +1 -0
- package/build/index.d.ts +14 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +13 -0
- package/build/index.js.map +1 -0
- package/build/mappers.d.ts +15 -0
- package/build/mappers.d.ts.map +1 -0
- package/build/mappers.js +149 -0
- package/build/mappers.js.map +1 -0
- package/build/types.d.ts +183 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/expo-module.config.json +6 -0
- package/package.json +68 -0
- package/src/ExpoYtDlpModule.ts +41 -0
- package/src/ExpoYtDlpModule.web.ts +15 -0
- package/src/YtDlp.ts +151 -0
- package/src/constants.ts +11 -0
- package/src/downloadTask.ts +159 -0
- package/src/errors.ts +133 -0
- package/src/events.ts +116 -0
- package/src/index.ts +17 -0
- package/src/mappers.ts +168 -0
- package/src/types.ts +219 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
package expo.modules.ytdlp
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import com.chaquo.python.Kwarg
|
|
5
|
+
import com.chaquo.python.PyObject
|
|
6
|
+
import com.chaquo.python.Python
|
|
7
|
+
import dev.ffmpegkit_maintained.ytdlp.YtDlp
|
|
8
|
+
import dev.ffmpegkit_maintained.ytdlp.YtDlpException
|
|
9
|
+
import java.io.File
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Adapter isolating every call into the third-party `yt-dlp-android` library
|
|
13
|
+
* and its bundled Chaquopy/Python runtime.
|
|
14
|
+
*
|
|
15
|
+
* Only this file talks to `dev.ffmpegkit_maintained.ytdlp` and
|
|
16
|
+
* `com.chaquo.python`. Everything above relies on this adapter.
|
|
17
|
+
*/
|
|
18
|
+
internal object YtDlpEngine {
|
|
19
|
+
|
|
20
|
+
@Volatile
|
|
21
|
+
private var initialized = false
|
|
22
|
+
|
|
23
|
+
/** Starts the embedded Python runtime. Safe to call repeatedly. Not on the main thread. */
|
|
24
|
+
@Synchronized
|
|
25
|
+
fun ensureInitialized(context: Context) {
|
|
26
|
+
if (initialized) return
|
|
27
|
+
try {
|
|
28
|
+
YtDlp.init(context.applicationContext)
|
|
29
|
+
initialized = true
|
|
30
|
+
} catch (e: YtDlpException) {
|
|
31
|
+
throw YtDlpNativeException("INIT_FAILED", "Failed to initialize the yt-dlp runtime.", e)
|
|
32
|
+
} catch (e: Throwable) {
|
|
33
|
+
throw YtDlpNativeException(
|
|
34
|
+
"INIT_FAILED",
|
|
35
|
+
"The yt-dlp runtime is unavailable on this device (its native library may be missing).",
|
|
36
|
+
e,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Reads the bundled yt-dlp version via Chaquopy. */
|
|
42
|
+
fun getYtDlpVersion(): String {
|
|
43
|
+
try {
|
|
44
|
+
val module = Python.getInstance().getModule("yt_dlp.version")
|
|
45
|
+
return module.get("__version__").toString()
|
|
46
|
+
} catch (e: Exception) {
|
|
47
|
+
throw YtDlpNativeException("VERSION_UNREADABLE", "Failed to read the yt-dlp version.", e)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Extracts media information for [url] without downloading anything and
|
|
53
|
+
* returns it serialized as JSON (see AGENTS.md §34).
|
|
54
|
+
*
|
|
55
|
+
* The bundled `yt_dlp` package is driven directly through Chaquopy because
|
|
56
|
+
* the wrapper's own `ytdlp_runner` exposes no extraction/output-capture API.
|
|
57
|
+
*/
|
|
58
|
+
fun extractInfoJson(url: String, options: Map<String, Any?>?): String {
|
|
59
|
+
validateUrl(url)
|
|
60
|
+
try {
|
|
61
|
+
val py = Python.getInstance()
|
|
62
|
+
val opts = toPyObject(py, extractOptions(options))
|
|
63
|
+
val ydlClass = py.getModule("yt_dlp").get("YoutubeDL")!!
|
|
64
|
+
val ydl = ydlClass.call(opts)
|
|
65
|
+
val info = ydl.callAttr("extract_info", url, Kwarg("download", false))
|
|
66
|
+
val sanitized = ydl.callAttr("sanitize_info", info)
|
|
67
|
+
val json = py.getModule("json")
|
|
68
|
+
return json.callAttr("dumps", sanitized, Kwarg("ensure_ascii", false)).toString()
|
|
69
|
+
} catch (e: YtDlpNativeException) {
|
|
70
|
+
throw e
|
|
71
|
+
} catch (e: Exception) {
|
|
72
|
+
throw YtDlpNativeException("EXTRACTION_FAILED", "Failed to extract information: ${e.message}", e)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Builds the yt-dlp options dict shared by extraction and download. */
|
|
77
|
+
private fun extractOptions(options: Map<String, Any?>?): MutableMap<String, Any> {
|
|
78
|
+
val opts = mutableMapOf<String, Any>("skip_download" to true)
|
|
79
|
+
applyCommonOptions(opts, options)
|
|
80
|
+
return opts
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private fun applyCommonOptions(opts: MutableMap<String, Any>, options: Map<String, Any?>?) {
|
|
84
|
+
if (options == null) return
|
|
85
|
+
val cookies = options["cookies"] as? Map<*, *>
|
|
86
|
+
(cookies?.get("path") as? String)?.let { opts["cookiefile"] = it }
|
|
87
|
+
(options["proxy"] as? String)?.let { opts["proxy"] = it }
|
|
88
|
+
val headers = mutableMapOf<String, Any>()
|
|
89
|
+
(options["headers"] as? Map<*, *>)?.forEach { (key, value) ->
|
|
90
|
+
if (key != null && value != null) headers[key.toString()] = value.toString()
|
|
91
|
+
}
|
|
92
|
+
(options["userAgent"] as? String)?.let { headers["User-Agent"] = it }
|
|
93
|
+
if (headers.isNotEmpty()) opts["http_headers"] = headers
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Runs the download for [task]. Drives yt-dlp directly through Chaquopy so
|
|
98
|
+
* that our own progress hook can abort natively on cancellation (see
|
|
99
|
+
* AGENTS.md §22 and [YtDlpTask]).
|
|
100
|
+
*
|
|
101
|
+
* Throws a classified [YtDlpNativeException] on failure.
|
|
102
|
+
*/
|
|
103
|
+
fun executeDownload(task: YtDlpTask, options: Map<String, Any?>, outputDirectory: File) {
|
|
104
|
+
val url = (options["url"] as? String)?.trim().takeIf { !it.isNullOrBlank() }
|
|
105
|
+
?: throw YtDlpNativeException("INVALID_URL", "URL is required.")
|
|
106
|
+
try {
|
|
107
|
+
rejectUnsupportedFeatures(options)
|
|
108
|
+
val py = Python.getInstance()
|
|
109
|
+
val opts = buildDownloadOptions(options, outputDirectory)
|
|
110
|
+
downloaderFunction(py).callAttr("execute", task, url, toPyObject(py, opts))
|
|
111
|
+
} catch (e: YtDlpNativeException) {
|
|
112
|
+
throw e
|
|
113
|
+
} catch (e: com.chaquo.python.PyException) {
|
|
114
|
+
val message = e.message ?: ""
|
|
115
|
+
if (task.isCancelRequested() || message.contains(CANCEL_SENTINEL)) {
|
|
116
|
+
throw YtDlpNativeException("CANCELLED", "Download cancelled.")
|
|
117
|
+
}
|
|
118
|
+
throw YtDlpNativeException(classifyDownloadFailure(message), message, e)
|
|
119
|
+
} catch (e: Exception) {
|
|
120
|
+
throw YtDlpNativeException("DOWNLOAD_FAILED", "Download failed: ${e.message}", e)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private fun buildDownloadOptions(options: Map<String, Any?>, outputDirectory: File): MutableMap<String, Any> {
|
|
125
|
+
val opts = mutableMapOf<String, Any>()
|
|
126
|
+
(options["format"] as? String)?.takeIf { it.isNotBlank() }?.let { opts["format"] = it }
|
|
127
|
+
val filename = (options["output"] as? Map<*, *>)?.get("filename") as? String
|
|
128
|
+
val sanitizedTemplate = YtDlpFileUtil.sanitizeFilenameTemplate(filename ?: YtDlpFileUtil.DEFAULT_FILENAME)
|
|
129
|
+
opts["outtmpl"] = File(outputDirectory, sanitizedTemplate).absolutePath
|
|
130
|
+
opts["quiet"] = true
|
|
131
|
+
|
|
132
|
+
applyCommonOptions(opts, options)
|
|
133
|
+
|
|
134
|
+
(options["referer"] as? String)?.takeIf { it.isNotBlank() }?.let { opts["http_referer"] = it }
|
|
135
|
+
|
|
136
|
+
val playlist = options["playlist"] as? Map<*, *>
|
|
137
|
+
if (playlist?.get("enabled") != true) opts["noplaylist"] = true
|
|
138
|
+
(playlist?.get("start") as? Number)?.takeIf { it.toInt() > 0 }?.let { opts["playliststart"] = it.toInt() }
|
|
139
|
+
(playlist?.get("end") as? Number)?.takeIf { it.toInt() > 0 }?.let { opts["playlistend"] = it.toInt() }
|
|
140
|
+
|
|
141
|
+
val subtitles = options["subtitles"] as? Map<*, *>
|
|
142
|
+
if (subtitles?.get("enabled") == true) {
|
|
143
|
+
opts["writesubtitles"] = true
|
|
144
|
+
(subtitles["languages"] as? List<*>)?.mapNotNull { it as? String }?.takeIf { it.isNotEmpty() }
|
|
145
|
+
?.let { opts["subtitleslangs"] = it }
|
|
146
|
+
if (subtitles["autoGenerated"] == true) opts["writeautomaticsub"] = true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
val network = options["network"] as? Map<*, *>
|
|
150
|
+
(network?.get("timeout") as? Number)?.takeIf { it.toInt() > 0 }?.let { opts["socket_timeout"] = it.toInt() }
|
|
151
|
+
(network?.get("retries") as? Number)?.takeIf { it.toInt() >= 0 }?.let { opts["retries"] = it.toInt() }
|
|
152
|
+
|
|
153
|
+
return opts
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Anything we cannot honestly support is rejected up front (AGENTS.md §19). */
|
|
157
|
+
private fun rejectUnsupportedFeatures(options: Map<String, Any?>) {
|
|
158
|
+
if (options["merge"] == true) {
|
|
159
|
+
throw YtDlpNativeException(
|
|
160
|
+
"PROCESSING_FAILED",
|
|
161
|
+
"Merging video and audio requires FFmpeg, which is not bundled with yt-dlp-android.",
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
val audio = options["audio"] as? Map<*, *>
|
|
165
|
+
if (audio?.get("only") == true || audio?.get("format") != null || audio?.get("quality") != null) {
|
|
166
|
+
throw YtDlpNativeException(
|
|
167
|
+
"PROCESSING_FAILED",
|
|
168
|
+
"Audio extraction and re-encoding require FFmpeg, which is not bundled with yt-dlp-android.",
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
if (options["metadata"] != null || options["thumbnail"] != null) {
|
|
172
|
+
throw YtDlpNativeException(
|
|
173
|
+
"PROCESSING_FAILED",
|
|
174
|
+
"Metadata and thumbnail embedding are not supported in this version.",
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private fun classifyDownloadFailure(message: String): String {
|
|
180
|
+
val haystack = message.lowercase()
|
|
181
|
+
return when {
|
|
182
|
+
haystack.contains("youtube") && haystack.contains("sign in to confirm") -> "AUTHENTICATION_REQUIRED"
|
|
183
|
+
haystack.contains("login required") || haystack.contains("log in") || haystack.contains("sign up") ->
|
|
184
|
+
"AUTHENTICATION_REQUIRED"
|
|
185
|
+
haystack.contains("private") || haystack.contains("members-only") -> "PRIVATE_CONTENT"
|
|
186
|
+
haystack.contains("age-") || haystack.contains("age restricted") || haystack.contains("mature") ->
|
|
187
|
+
"AGE_RESTRICTED"
|
|
188
|
+
haystack.contains("geo") || haystack.contains("not available in your country") -> "GEO_RESTRICTED"
|
|
189
|
+
haystack.contains("ffmpeg") || haystack.contains("postprocess") || haystack.contains("merge") ->
|
|
190
|
+
"PROCESSING_FAILED"
|
|
191
|
+
haystack.contains("requested format") || haystack.contains("no video formats") ||
|
|
192
|
+
haystack.contains("format combination") -> "FORMAT_UNAVAILABLE"
|
|
193
|
+
haystack.contains("http error") || haystack.contains("connection") || haystack.contains("timed out") ||
|
|
194
|
+
haystack.contains("timeout") || haystack.contains("unreachable") || haystack.contains("reset by peer") ||
|
|
195
|
+
haystack.contains("couldn't connect") || haystack.contains("503") || haystack.contains("429") ->
|
|
196
|
+
"NETWORK_ERROR"
|
|
197
|
+
else -> "DOWNLOAD_FAILED"
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Lazily execs (once) the Python helper used to run downloads. */
|
|
202
|
+
private fun downloaderFunction(py: Python): PyObject {
|
|
203
|
+
return downloaderStore.getOrPut(py) {
|
|
204
|
+
val builtins = py.getBuiltins()
|
|
205
|
+
val ns = builtins.callAttr("dict")
|
|
206
|
+
builtins.callAttr("exec", DOWNLOAD_HELPER_SCRIPT, ns, ns)
|
|
207
|
+
// `ns` is a dict, so `execute` is an *item*, not an attribute: `PyObject.get`
|
|
208
|
+
// (attribute access) would return null. Use the container view instead.
|
|
209
|
+
ns.asMap()[PyObject.fromJava("execute")]
|
|
210
|
+
?: throw YtDlpNativeException("INIT_FAILED", "The download helper is unavailable.")
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private fun validateUrl(url: String?) {
|
|
215
|
+
if (url.isNullOrBlank()) {
|
|
216
|
+
throw YtDlpNativeException("INVALID_URL", "URL is required.")
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Converts a Kotlin value into a genuine Python object. Chaquopy does not
|
|
222
|
+
* automatically convert Java Maps/Lists into Python containers (see
|
|
223
|
+
* chaquo/chaquopy#1048), so they would otherwise reach yt-dlp as opaque
|
|
224
|
+
* `java.util.LinkedHashMap` proxies and break `dict.get(key, default)` calls.
|
|
225
|
+
*/
|
|
226
|
+
private fun toPyObject(py: Python, value: Any?): PyObject {
|
|
227
|
+
return when (value) {
|
|
228
|
+
null -> PyObject.fromJava(null)
|
|
229
|
+
is PyObject -> value
|
|
230
|
+
is Map<*, *> -> {
|
|
231
|
+
val d = py.getBuiltins().callAttr("dict")
|
|
232
|
+
val view = d.asMap()
|
|
233
|
+
value.forEach { (key, v) ->
|
|
234
|
+
if (key != null && v != null) {
|
|
235
|
+
view.put(PyObject.fromJava(key.toString()), toPyObject(py, v))
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
d
|
|
239
|
+
}
|
|
240
|
+
is List<*> -> {
|
|
241
|
+
val l = py.getBuiltins().callAttr("list")
|
|
242
|
+
val view = l.asList()
|
|
243
|
+
value.filterNotNull().forEach { view.add(toPyObject(py, it)) }
|
|
244
|
+
l
|
|
245
|
+
}
|
|
246
|
+
else -> PyObject.fromJava(value)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private const val CANCEL_SENTINEL = "YTDLP_CANCELLED"
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Drive the bundled yt-dlp directly. Importing is deferred so our first
|
|
254
|
+
* call stays fast. `execute` returns the download retcode (0 = success);
|
|
255
|
+
* raising from the progress hook aborts yt-dlp natively on cancellation.
|
|
256
|
+
*/
|
|
257
|
+
private val DOWNLOAD_HELPER_SCRIPT = """
|
|
258
|
+
def _expo_ytdlp_hook(task, d):
|
|
259
|
+
if task.isCancelRequested():
|
|
260
|
+
raise RuntimeError("YTDLP_CANCELLED")
|
|
261
|
+
downloaded = int(d.get('downloaded_bytes') or 0)
|
|
262
|
+
total = int(d.get('total_bytes') or d.get('total_bytes_estimate') or 0)
|
|
263
|
+
speed = int(d.get('speed') or 0)
|
|
264
|
+
eta = int(d.get('eta') or 0)
|
|
265
|
+
filename = str(d.get('filename') or '')
|
|
266
|
+
task.onPythonProgress(downloaded, total, speed, eta, filename)
|
|
267
|
+
|
|
268
|
+
def execute(task, url, opts):
|
|
269
|
+
import yt_dlp
|
|
270
|
+
opts = dict(opts)
|
|
271
|
+
opts['progress_hooks'] = [lambda d, t=task: _expo_ytdlp_hook(t, d)]
|
|
272
|
+
with yt_dlp.YoutubeDL(opts) as ydl:
|
|
273
|
+
return ydl.download([url])
|
|
274
|
+
""".trimIndent()
|
|
275
|
+
|
|
276
|
+
private val downloaderStore = java.util.concurrent.ConcurrentHashMap<Python, PyObject>()
|
|
277
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
package expo.modules.ytdlp
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Internal exception carrying a public error code.
|
|
5
|
+
*
|
|
6
|
+
* The code is embedded in a prefixed [message] so that it survives the
|
|
7
|
+
* Expo Modules bridge, which only guarantees `message` (and a generic
|
|
8
|
+
* `code`) to JavaScript. The TypeScript layer parses the prefix.
|
|
9
|
+
*/
|
|
10
|
+
internal class YtDlpNativeException(
|
|
11
|
+
val errorCode: String,
|
|
12
|
+
userMessage: String,
|
|
13
|
+
cause: Throwable? = null,
|
|
14
|
+
) : Exception("YTD_NATIVE|$errorCode|$userMessage", cause)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
package expo.modules.ytdlp
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Safe handling of the yt-dlp output location.
|
|
7
|
+
*
|
|
8
|
+
* All output lives inside app-specific storage (see AGENTS.md §24). User
|
|
9
|
+
* strings are treated as untrusted input: no path traversal, no illegal
|
|
10
|
+
* characters, no absurd lengths (see AGENTS.md §25, §26).
|
|
11
|
+
*/
|
|
12
|
+
internal object YtDlpFileUtil {
|
|
13
|
+
|
|
14
|
+
const val DEFAULT_DIRECTORY = "Downloads"
|
|
15
|
+
const val DEFAULT_FILENAME = "%(title)s.%(ext)s"
|
|
16
|
+
|
|
17
|
+
/** Single-segment directory name; separators and `..` are removed. */
|
|
18
|
+
fun sanitizeDirectorySegment(name: String): String {
|
|
19
|
+
val cleaned = name
|
|
20
|
+
.replace(Regex("[\\\\/]"), "_")
|
|
21
|
+
.replace(Regex("[\\p{Cntrl}]"), "")
|
|
22
|
+
.replace("..", "_")
|
|
23
|
+
.replace(Regex("^[.\\s]+"), "")
|
|
24
|
+
.take(64)
|
|
25
|
+
return cleaned.ifBlank { DEFAULT_DIRECTORY }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sanitizes a yt-dlp output template. `%(...)s` directives are preserved,
|
|
30
|
+
* every other character is scrubbed to stay safe on the filesystem.
|
|
31
|
+
*/
|
|
32
|
+
fun sanitizeFilenameTemplate(template: String): String {
|
|
33
|
+
if (template.isBlank()) return DEFAULT_FILENAME
|
|
34
|
+
val directive = Regex("%\\([^)]*\\)s")
|
|
35
|
+
val out = StringBuilder()
|
|
36
|
+
var index = 0
|
|
37
|
+
for (match in directive.findAll(template)) {
|
|
38
|
+
out.append(sanitizeStatic(template.substring(index, match.range.first)))
|
|
39
|
+
out.append(match.value)
|
|
40
|
+
index = match.range.last + 1
|
|
41
|
+
}
|
|
42
|
+
out.append(sanitizeStatic(template.substring(index)))
|
|
43
|
+
return out.toString().ifBlank { DEFAULT_FILENAME }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fun resolveOutputDirectory(baseDir: File, requested: String?): File {
|
|
47
|
+
val name = requested?.takeIf { it.isNotBlank() } ?: DEFAULT_DIRECTORY
|
|
48
|
+
val dir = File(baseDir, sanitizeDirectorySegment(name))
|
|
49
|
+
if (!dir.exists() && !dir.mkdirs()) {
|
|
50
|
+
throw YtDlpNativeException("STORAGE_ERROR", "Could not create output directory.")
|
|
51
|
+
}
|
|
52
|
+
if (!dir.isDirectory) {
|
|
53
|
+
throw YtDlpNativeException("STORAGE_ERROR", "Output path is not a directory.")
|
|
54
|
+
}
|
|
55
|
+
return dir
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Returns the newest file created under [baseDir] after [createdAfter]. */
|
|
59
|
+
fun findNewestFile(baseDir: File, createdAfter: Long): File? {
|
|
60
|
+
if (!baseDir.exists()) return null
|
|
61
|
+
return baseDir
|
|
62
|
+
.walkTopDown()
|
|
63
|
+
.filter { it.isFile && it.lastModified() >= createdAfter - GRACE_MS }
|
|
64
|
+
.maxByOrNull { it.lastModified() }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Moves a file back inside [baseDir] if it escaped (path traversal defense). */
|
|
68
|
+
fun ensureContained(file: File, baseDir: File): File {
|
|
69
|
+
val base = baseDir.canonicalFile
|
|
70
|
+
val fileCanonical = file.canonicalFile
|
|
71
|
+
if (fileCanonical.path == base.path || fileCanonical.path.startsWith(base.path + File.separator)) {
|
|
72
|
+
return file
|
|
73
|
+
}
|
|
74
|
+
val target = File(baseDir, file.name)
|
|
75
|
+
if (file.renameTo(target)) {
|
|
76
|
+
return target
|
|
77
|
+
}
|
|
78
|
+
throw YtDlpNativeException("STORAGE_ERROR", "Downloaded file could not be secured in the output directory.")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private fun sanitizeStatic(part: String): String {
|
|
82
|
+
return part
|
|
83
|
+
.replace(Regex("[\\\\/:*?\"<>|\\p{Cntrl}]"), "_")
|
|
84
|
+
.replace("..", "_")
|
|
85
|
+
.take(240)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private const val GRACE_MS = 60_000L
|
|
89
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
package expo.modules.ytdlp
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
import java.util.UUID
|
|
5
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
6
|
+
import java.util.concurrent.atomic.AtomicLong
|
|
7
|
+
|
|
8
|
+
internal enum class YtDlpStatus {
|
|
9
|
+
QUEUED,
|
|
10
|
+
EXTRACTING,
|
|
11
|
+
DOWNLOADING,
|
|
12
|
+
PROCESSING,
|
|
13
|
+
COMPLETED,
|
|
14
|
+
CANCELLED,
|
|
15
|
+
FAILED,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A single download unit.
|
|
20
|
+
*
|
|
21
|
+
* Downloads are driven directly through Chaquopy (not the wrapper's runner),
|
|
22
|
+
* because the wrapper's Python runner wraps its progress hook in
|
|
23
|
+
* `try/except Exception` and swallows any exception we throw from Java — that
|
|
24
|
+
* would make cancellation impossible (see AGENTS.md §22). Instead our own
|
|
25
|
+
* Python hook calls back into Java through [onPythonProgress] and checks
|
|
26
|
+
* [isCancelRequested]; when cancellation is requested, the hook raises
|
|
27
|
+
* `RuntimeError("YTDLP_CANCELLED")` inside yt-dlp, aborting it natively.
|
|
28
|
+
*/
|
|
29
|
+
internal class YtDlpTask(
|
|
30
|
+
val id: String = UUID.randomUUID().toString(),
|
|
31
|
+
val outputDirectory: File,
|
|
32
|
+
private val onEvent: (Map<String, Any>) -> Unit,
|
|
33
|
+
) {
|
|
34
|
+
|
|
35
|
+
@Volatile
|
|
36
|
+
var status: YtDlpStatus = YtDlpStatus.QUEUED
|
|
37
|
+
private set
|
|
38
|
+
|
|
39
|
+
@Volatile
|
|
40
|
+
var errorCode: String? = null
|
|
41
|
+
|
|
42
|
+
@Volatile
|
|
43
|
+
var errorMessage: String? = null
|
|
44
|
+
|
|
45
|
+
@Volatile
|
|
46
|
+
var startTime: Long = System.currentTimeMillis()
|
|
47
|
+
|
|
48
|
+
private val cancelRequested = AtomicBoolean(false)
|
|
49
|
+
internal val snapshot = ProgressSnapshot()
|
|
50
|
+
private val lastEmit = AtomicLong(0L)
|
|
51
|
+
|
|
52
|
+
fun isCancelRequested(): Boolean = cancelRequested.get()
|
|
53
|
+
|
|
54
|
+
fun requestCancel(): Boolean = cancelRequested.compareAndSet(false, true)
|
|
55
|
+
|
|
56
|
+
@Synchronized
|
|
57
|
+
fun setStatus(newStatus: YtDlpStatus) {
|
|
58
|
+
if (status == YtDlpStatus.COMPLETED || status == YtDlpStatus.CANCELLED || status == YtDlpStatus.FAILED) return
|
|
59
|
+
status = newStatus
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Called from Python (via the Chaquopy bridge) on every progress tick.
|
|
64
|
+
* Structured numbers come straight from yt-dlp's progress hook dict.
|
|
65
|
+
*/
|
|
66
|
+
fun onPythonProgress(
|
|
67
|
+
downloadedBytes: Long,
|
|
68
|
+
totalBytes: Long,
|
|
69
|
+
speedBytesPerSecond: Long,
|
|
70
|
+
etaSeconds: Long,
|
|
71
|
+
filename: String,
|
|
72
|
+
) {
|
|
73
|
+
if (cancelRequested.get()) return
|
|
74
|
+
snapshot.update(downloadedBytes, totalBytes, speedBytesPerSecond, etaSeconds, filename)
|
|
75
|
+
when {
|
|
76
|
+
status == YtDlpStatus.QUEUED -> setStatus(YtDlpStatus.EXTRACTING)
|
|
77
|
+
status == YtDlpStatus.EXTRACTING && (snapshot.downloadedBytes ?: 0L) > 0L ->
|
|
78
|
+
setStatus(YtDlpStatus.DOWNLOADING)
|
|
79
|
+
}
|
|
80
|
+
emitProgress()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
fun emitState() {
|
|
84
|
+
onEvent(statePayload())
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fun emitCompleted(resultFile: File?) {
|
|
88
|
+
setStatus(YtDlpStatus.COMPLETED)
|
|
89
|
+
onEvent(completedPayload(resultFile))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fun emitCancelled() {
|
|
93
|
+
setStatus(YtDlpStatus.CANCELLED)
|
|
94
|
+
onEvent(cancelledPayload())
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fun emitError(code: String, message: String) {
|
|
98
|
+
errorCode = code
|
|
99
|
+
errorMessage = message
|
|
100
|
+
setStatus(YtDlpStatus.FAILED)
|
|
101
|
+
onEvent(errorPayload())
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private fun emitProgress() {
|
|
105
|
+
val now = System.nanoTime()
|
|
106
|
+
if (now - lastEmit.get() >= THROTTLE_NANOS) {
|
|
107
|
+
lastEmit.set(now)
|
|
108
|
+
onEvent(progressPayload())
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private fun phaseOf(): String = when (status) {
|
|
113
|
+
YtDlpStatus.QUEUED, YtDlpStatus.EXTRACTING -> "extracting"
|
|
114
|
+
YtDlpStatus.DOWNLOADING -> "downloading"
|
|
115
|
+
YtDlpStatus.PROCESSING, YtDlpStatus.COMPLETED -> "processing"
|
|
116
|
+
YtDlpStatus.CANCELLED, YtDlpStatus.FAILED -> "downloading"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private fun progressPayload(): Map<String, Any> = mapOf(
|
|
120
|
+
"type" to "progress",
|
|
121
|
+
"taskId" to id,
|
|
122
|
+
"status" to status.name.lowercase(),
|
|
123
|
+
"phase" to phaseOf(),
|
|
124
|
+
"progress" to mapOf(
|
|
125
|
+
"percent" to snapshot.percent,
|
|
126
|
+
"downloadedBytes" to snapshot.downloadedBytes,
|
|
127
|
+
"totalBytes" to snapshot.totalBytes,
|
|
128
|
+
"speedBytesPerSecond" to snapshot.speedBytesPerSecond,
|
|
129
|
+
"etaSeconds" to snapshot.etaSeconds,
|
|
130
|
+
"filename" to snapshot.filename,
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
private fun statePayload(): Map<String, Any> = mapOf(
|
|
135
|
+
"type" to "state",
|
|
136
|
+
"taskId" to id,
|
|
137
|
+
"status" to status.name.lowercase(),
|
|
138
|
+
"phase" to phaseOf(),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
private fun completedPayload(resultFile: File?): Map<String, Any> {
|
|
142
|
+
val size = resultFile?.length() ?: 0L
|
|
143
|
+
return mapOf(
|
|
144
|
+
"type" to "completed",
|
|
145
|
+
"taskId" to id,
|
|
146
|
+
"status" to "completed",
|
|
147
|
+
"phase" to "processing",
|
|
148
|
+
"result" to mapOf(
|
|
149
|
+
"taskId" to id,
|
|
150
|
+
"path" to (resultFile?.absolutePath ?: null),
|
|
151
|
+
"uri" to null,
|
|
152
|
+
"filename" to (resultFile?.name ?: null),
|
|
153
|
+
"mimeType" to null,
|
|
154
|
+
"size" to (if (size > 0) size else null),
|
|
155
|
+
"duration" to null,
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private fun errorPayload(): Map<String, Any> = mapOf(
|
|
161
|
+
"type" to "error",
|
|
162
|
+
"taskId" to id,
|
|
163
|
+
"status" to "failed",
|
|
164
|
+
"phase" to phaseOf(),
|
|
165
|
+
"error" to mapOf(
|
|
166
|
+
"code" to (errorCode ?: "DOWNLOAD_FAILED"),
|
|
167
|
+
"message" to (errorMessage ?: "Download failed"),
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
private fun cancelledPayload(): Map<String, Any> = mapOf(
|
|
172
|
+
"type" to "cancelled",
|
|
173
|
+
"taskId" to id,
|
|
174
|
+
"status" to "cancelled",
|
|
175
|
+
"phase" to "downloading",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
private companion object {
|
|
179
|
+
const val THROTTLE_NANOS = 200_000_000L // 200 ms, see AGENTS.md §40
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Rolling view of the latest structured download progress numbers. */
|
|
184
|
+
internal class ProgressSnapshot {
|
|
185
|
+
@Volatile
|
|
186
|
+
var percent: Double? = null
|
|
187
|
+
private set
|
|
188
|
+
|
|
189
|
+
@Volatile
|
|
190
|
+
var downloadedBytes: Long? = null
|
|
191
|
+
private set
|
|
192
|
+
|
|
193
|
+
@Volatile
|
|
194
|
+
var totalBytes: Long? = null
|
|
195
|
+
private set
|
|
196
|
+
|
|
197
|
+
@Volatile
|
|
198
|
+
var speedBytesPerSecond: Long? = null
|
|
199
|
+
private set
|
|
200
|
+
|
|
201
|
+
@Volatile
|
|
202
|
+
var etaSeconds: Long? = null
|
|
203
|
+
private set
|
|
204
|
+
|
|
205
|
+
@Volatile
|
|
206
|
+
var filename: String? = null
|
|
207
|
+
private set
|
|
208
|
+
|
|
209
|
+
fun update(
|
|
210
|
+
downloaded: Long,
|
|
211
|
+
total: Long,
|
|
212
|
+
speed: Long,
|
|
213
|
+
eta: Long,
|
|
214
|
+
name: String,
|
|
215
|
+
) {
|
|
216
|
+
if (downloaded > 0L) downloadedBytes = downloaded
|
|
217
|
+
if (total > 0L) {
|
|
218
|
+
totalBytes = total
|
|
219
|
+
percent = downloaded.coerceAtMost(total) * 100.0 / total
|
|
220
|
+
}
|
|
221
|
+
if (speed > 0L) speedBytesPerSecond = speed
|
|
222
|
+
if (eta >= 0L) etaSeconds = eta
|
|
223
|
+
if (name.isNotBlank()) filename = name
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin typed binding to the native `ExpoYtDlp` module.
|
|
3
|
+
*
|
|
4
|
+
* On unsupported platforms (web/iOS) the native module is absent; we detect
|
|
5
|
+
* this here and surface it at call time as `UNSUPPORTED_PLATFORM`, so a plain
|
|
6
|
+
* import never crashes.
|
|
7
|
+
*/
|
|
8
|
+
import { NativeModule } from 'expo';
|
|
9
|
+
import type { DownloadEvent, DownloadStatus } from './types';
|
|
10
|
+
export interface DownloadTaskInfo {
|
|
11
|
+
taskId: string;
|
|
12
|
+
directory: string;
|
|
13
|
+
}
|
|
14
|
+
export interface DownloadStatusInfo {
|
|
15
|
+
taskId: string;
|
|
16
|
+
status: DownloadStatus;
|
|
17
|
+
percent?: number;
|
|
18
|
+
downloadedBytes?: number;
|
|
19
|
+
totalBytes?: number;
|
|
20
|
+
speedBytesPerSecond?: number;
|
|
21
|
+
etaSeconds?: number;
|
|
22
|
+
filename?: string;
|
|
23
|
+
}
|
|
24
|
+
export type ExpoYtDlpModuleEvents = {
|
|
25
|
+
downloadEvent: (event: DownloadEvent) => void;
|
|
26
|
+
};
|
|
27
|
+
export declare class ExpoYtDlpNativeModule extends NativeModule<ExpoYtDlpModuleEvents> {
|
|
28
|
+
getVersion(): Promise<string>;
|
|
29
|
+
extractInfo(url: string, options: Record<string, unknown>): Promise<string>;
|
|
30
|
+
startDownload(options: Record<string, unknown>): Promise<DownloadTaskInfo>;
|
|
31
|
+
cancelDownload(taskId: string): Promise<boolean>;
|
|
32
|
+
getDownloadStatus(taskId: string): Promise<DownloadStatusInfo | null>;
|
|
33
|
+
}
|
|
34
|
+
export declare const NativeExpoYtDlp: ExpoYtDlpNativeModule | null;
|
|
35
|
+
//# sourceMappingURL=ExpoYtDlpModule.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoYtDlpModule.d.ts","sourceRoot":"","sources":["../src/ExpoYtDlpModule.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,YAAY,EAA+B,MAAM,MAAM,CAAC;AAEjE,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE7D,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,cAAc,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,aAAa,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;CAC/C,CAAC;AAEF,MAAM,CAAC,OAAO,OAAO,qBAAsB,SAAQ,YAAY,CAAC,qBAAqB,CAAC;IACpF,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC;IAC7B,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC;IAC3E,aAAa,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAC1E,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAChD,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;CACtE;AAED,eAAO,MAAM,eAAe,EAAE,qBAAqB,GAAG,IACW,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin typed binding to the native `ExpoYtDlp` module.
|
|
3
|
+
*
|
|
4
|
+
* On unsupported platforms (web/iOS) the native module is absent; we detect
|
|
5
|
+
* this here and surface it at call time as `UNSUPPORTED_PLATFORM`, so a plain
|
|
6
|
+
* import never crashes.
|
|
7
|
+
*/
|
|
8
|
+
import { requireOptionalNativeModule } from 'expo';
|
|
9
|
+
export const NativeExpoYtDlp = requireOptionalNativeModule('ExpoYtDlp');
|
|
10
|
+
//# sourceMappingURL=ExpoYtDlpModule.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoYtDlpModule.js","sourceRoot":"","sources":["../src/ExpoYtDlpModule.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAgB,2BAA2B,EAAE,MAAM,MAAM,CAAC;AAgCjE,MAAM,CAAC,MAAM,eAAe,GAC1B,2BAA2B,CAAwB,WAAW,CAAC,CAAC","sourcesContent":["/**\n * Thin typed binding to the native `ExpoYtDlp` module.\n *\n * On unsupported platforms (web/iOS) the native module is absent; we detect\n * this here and surface it at call time as `UNSUPPORTED_PLATFORM`, so a plain\n * import never crashes.\n */\nimport { NativeModule, requireOptionalNativeModule } from 'expo';\n\nimport type { DownloadEvent, DownloadStatus } from './types';\n\nexport interface DownloadTaskInfo {\n taskId: string;\n directory: string;\n}\n\nexport interface DownloadStatusInfo {\n taskId: string;\n status: DownloadStatus;\n percent?: number;\n downloadedBytes?: number;\n totalBytes?: number;\n speedBytesPerSecond?: number;\n etaSeconds?: number;\n filename?: string;\n}\n\nexport type ExpoYtDlpModuleEvents = {\n downloadEvent: (event: DownloadEvent) => void;\n};\n\nexport declare class ExpoYtDlpNativeModule extends NativeModule<ExpoYtDlpModuleEvents> {\n getVersion(): Promise<string>;\n extractInfo(url: string, options: Record<string, unknown>): Promise<string>;\n startDownload(options: Record<string, unknown>): Promise<DownloadTaskInfo>;\n cancelDownload(taskId: string): Promise<boolean>;\n getDownloadStatus(taskId: string): Promise<DownloadStatusInfo | null>;\n}\n\nexport const NativeExpoYtDlp: ExpoYtDlpNativeModule | null =\n requireOptionalNativeModule<ExpoYtDlpNativeModule>('ExpoYtDlp');\n"]}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { NativeModule } from 'expo';
|
|
2
|
+
/**
|
|
3
|
+
* Web placeholder. The package is Android-only (see AGENTS.md §3); every
|
|
4
|
+
* method throws a meaningful error instead of a silent no-op.
|
|
5
|
+
*/
|
|
6
|
+
declare class ExpoYtDlpModule extends NativeModule {
|
|
7
|
+
getVersion(): Promise<string>;
|
|
8
|
+
}
|
|
9
|
+
declare const _default: typeof ExpoYtDlpModule;
|
|
10
|
+
export default _default;
|
|
11
|
+
//# sourceMappingURL=ExpoYtDlpModule.web.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoYtDlpModule.web.d.ts","sourceRoot":"","sources":["../src/ExpoYtDlpModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAqB,MAAM,MAAM,CAAC;AAIvD;;;GAGG;AACH,cAAM,eAAgB,SAAQ,YAAY;IACxC,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC;CAG9B;;AAED,wBAA+D"}
|