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.
Files changed (63) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/LICENSE +21 -0
  3. package/README.md +267 -0
  4. package/android/build.gradle +25 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/expo/modules/ytdlp/ExpoYtDlpModule.kt +74 -0
  7. package/android/src/main/java/expo/modules/ytdlp/YtDlpDownloadManager.kt +97 -0
  8. package/android/src/main/java/expo/modules/ytdlp/YtDlpEngine.kt +277 -0
  9. package/android/src/main/java/expo/modules/ytdlp/YtDlpException.kt +14 -0
  10. package/android/src/main/java/expo/modules/ytdlp/YtDlpFileUtil.kt +89 -0
  11. package/android/src/main/java/expo/modules/ytdlp/YtDlpTask.kt +225 -0
  12. package/build/ExpoYtDlpModule.d.ts +35 -0
  13. package/build/ExpoYtDlpModule.d.ts.map +1 -0
  14. package/build/ExpoYtDlpModule.js +10 -0
  15. package/build/ExpoYtDlpModule.js.map +1 -0
  16. package/build/ExpoYtDlpModule.web.d.ts +11 -0
  17. package/build/ExpoYtDlpModule.web.d.ts.map +1 -0
  18. package/build/ExpoYtDlpModule.web.js +13 -0
  19. package/build/ExpoYtDlpModule.web.js.map +1 -0
  20. package/build/YtDlp.d.ts +23 -0
  21. package/build/YtDlp.d.ts.map +1 -0
  22. package/build/YtDlp.js +160 -0
  23. package/build/YtDlp.js.map +1 -0
  24. package/build/constants.d.ts +9 -0
  25. package/build/constants.d.ts.map +1 -0
  26. package/build/constants.js +9 -0
  27. package/build/constants.js.map +1 -0
  28. package/build/downloadTask.d.ts +30 -0
  29. package/build/downloadTask.d.ts.map +1 -0
  30. package/build/downloadTask.js +111 -0
  31. package/build/downloadTask.js.map +1 -0
  32. package/build/errors.d.ts +27 -0
  33. package/build/errors.d.ts.map +1 -0
  34. package/build/errors.js +111 -0
  35. package/build/errors.js.map +1 -0
  36. package/build/events.d.ts +20 -0
  37. package/build/events.d.ts.map +1 -0
  38. package/build/events.js +96 -0
  39. package/build/events.js.map +1 -0
  40. package/build/index.d.ts +14 -0
  41. package/build/index.d.ts.map +1 -0
  42. package/build/index.js +13 -0
  43. package/build/index.js.map +1 -0
  44. package/build/mappers.d.ts +15 -0
  45. package/build/mappers.d.ts.map +1 -0
  46. package/build/mappers.js +149 -0
  47. package/build/mappers.js.map +1 -0
  48. package/build/types.d.ts +183 -0
  49. package/build/types.d.ts.map +1 -0
  50. package/build/types.js +2 -0
  51. package/build/types.js.map +1 -0
  52. package/expo-module.config.json +6 -0
  53. package/package.json +68 -0
  54. package/src/ExpoYtDlpModule.ts +41 -0
  55. package/src/ExpoYtDlpModule.web.ts +15 -0
  56. package/src/YtDlp.ts +151 -0
  57. package/src/constants.ts +11 -0
  58. package/src/downloadTask.ts +159 -0
  59. package/src/errors.ts +133 -0
  60. package/src/events.ts +116 -0
  61. package/src/index.ts +17 -0
  62. package/src/mappers.ts +168 -0
  63. 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"}