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
package/CHANGELOG.md ADDED
@@ -0,0 +1,74 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.3] - 2026-08-17
11
+
12
+ ### Fixed
13
+
14
+ - Downloads no longer fail with `Download failed: null`. The Python `execute`
15
+ helper was fetched from the exec namespace with attribute access
16
+ (`PyObject.get`) even though it is a dict item; the downloader now uses
17
+ container access (`ns.asMap()`), which makes `YtDlp.download(...)` work.
18
+
19
+ ## [0.1.2] - 2026-08-17
20
+
21
+ ### Fixed
22
+
23
+ - Chaquopy 17 does not convert Java maps/lists to Python dicts/lists
24
+ automatically. Download and extraction options are now converted explicitly
25
+ so yt-dlp receives real Python containers.
26
+
27
+ ## [0.1.1] - 2026-08-17
28
+
29
+ ### Fixed
30
+
31
+ - Progress events now arrive through the native event emitter
32
+ (`module.addListener`), matching Expo SDK 52+ modules, instead of RN's
33
+ `NativeEventEmitter`.
34
+
35
+ ## [0.1.0] - 2026-08-16
36
+
37
+ ### Added
38
+
39
+ - Standalone Expo module for Android with the native yt-dlp engine embedded
40
+ via `dev.ffmpegkit-maintained:yt-dlp-android:2.0.2` (pinned).
41
+ - `YtDlp.getVersion()` — read the embedded yt-dlp version and the library version.
42
+ - `YtDlp.extractInfo(url, options?)` — extract normalized media information
43
+ without downloading.
44
+ - `YtDlp.getFormats(url, options?)` — list normalized download formats,
45
+ reusing the extraction result.
46
+ - `YtDlp.download(options)` — start a concurrent download task backed by a
47
+ native task registry.
48
+ - `YtDlp.cancel(taskId)` — cancel a task by id.
49
+ - `DownloadTask` with `progress` / `state` / `completed` / `error` listeners,
50
+ `cancel()`, `getStatus()` and `getProgress()`.
51
+ - Native cancellation via a custom Chaquopy progress hook that aborts yt-dlp.
52
+ - Progress events throttled to ~200 ms with structured numeric fields.
53
+ - Normalized `YtDlpError` codes and safe error translation across the bridge.
54
+ - Safe app-scoped file storage with filename/directory sanitization and
55
+ path-traversal protection.
56
+ - Supported options: `format`, `output`, `headers`, `userAgent`, `referer`,
57
+ `proxy`, `cookies.path`, `playlist`, `subtitles`, `network`.
58
+ - Example application (`example/`) for manual integration testing.
59
+
60
+ ### Unsupported
61
+
62
+ - iOS and web.
63
+ - FFmpeg-dependent features (merging, audio extraction/re-encoding, metadata
64
+ and thumbnail embedding) are rejected up front with `PROCESSING_FAILED`.
65
+ - Runtime updating of the embedded yt-dlp.
66
+ - Background/foreground-service downloads.
67
+
68
+ ### Security
69
+
70
+ - Added `SECURITY.md` with a vulnerability reporting policy.
71
+
72
+ ### Pinned dependency
73
+
74
+ - `dev.ffmpegkit-maintained:yt-dlp-android:2.0.2`
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Mehedi Hasan (devusimple)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,267 @@
1
+ # ytdlp-react-native
2
+
3
+ A native **Android** Expo module providing a modern TypeScript API around
4
+ [yt-dlp](https://github.com/yt-dlp/yt-dlp). It embeds the Python runtime,
5
+ yt-dlp and all site extractors through the
6
+ [yt-dlp-android](https://github.com/ffmpegkit-maintained/yt-dlp-android)
7
+ library — no Python, yt-dlp, Chaquopy, FFmpeg or Termux setup is required in
8
+ your app.
9
+
10
+ ```ts
11
+ import YtDlp from 'ytdlp-react-native';
12
+
13
+ const info = await YtDlp.extractInfo('https://www.youtube.com/watch?v=...');
14
+ console.log(info.title);
15
+ console.log(info.formats);
16
+
17
+ const task = await YtDlp.download({
18
+ url: 'https://www.youtube.com/watch?v=...',
19
+ format: 'bestvideo+bestaudio',
20
+ output: { directory: 'Movies' },
21
+ });
22
+
23
+ task.addListener('progress', (progress) => {
24
+ console.log(progress.percent);
25
+ });
26
+
27
+ await task.cancel();
28
+ ```
29
+
30
+ ## Requirements
31
+
32
+ - **Platform:** Android only. Importing on iOS/web throws
33
+ `YtDlpError` with code `UNSUPPORTED_PLATFORM`.
34
+ - **Expo SDK:** 57 (tested against Expo 57 / React Native 0.86).
35
+ - **Minimum Android:** API 24.
36
+ - **Native build required.** This is a custom native module. It does **not**
37
+ work inside standard Expo Go — Expo Go cannot load arbitrary custom native
38
+ modules. Use a development build:
39
+
40
+ ```bash
41
+ npx expo prebuild
42
+ npx expo run:android
43
+ ```
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ npx expo install ytdlp-react-native
49
+ ```
50
+
51
+ If `expo install` does not resolve the package (e.g. before it is indexed),
52
+ fall back to:
53
+
54
+ ```bash
55
+ npm install ytdlp-react-native
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ ### Extract media information
61
+
62
+ ```ts
63
+ import YtDlp from 'ytdlp-react-native';
64
+
65
+ const info = await YtDlp.extractInfo(url);
66
+ console.log(info.title); // string | undefined
67
+ console.log(info.duration);
68
+ console.log(info.thumbnail);
69
+ console.log(info.formats);
70
+ ```
71
+
72
+ `extractInfo` never downloads media. Fields that a site does not provide are
73
+ `undefined` — no field is guaranteed for every site.
74
+
75
+ ### List formats
76
+
77
+ ```ts
78
+ const formats = await YtDlp.getFormats(url);
79
+ ```
80
+
81
+ `getFormats` reuses the extraction result, so it does not extract twice.
82
+
83
+ ### Download
84
+
85
+ ```ts
86
+ const task = await YtDlp.download({
87
+ url,
88
+ format: 'bestvideo+bestaudio', // raw yt-dlp format expression
89
+ output: {
90
+ directory: 'Movies',
91
+ filename: '%(title)s.%(ext)s',
92
+ },
93
+ });
94
+
95
+ task.addListener('progress', (progress) => {
96
+ console.log(progress.percent, progress.speedBytesPerSecond, progress.etaSeconds);
97
+ });
98
+
99
+ task.addListener('completed', (result) => {
100
+ console.log(result.path);
101
+ });
102
+
103
+ task.addListener('error', (error) => {
104
+ console.log(error.code, error.message);
105
+ });
106
+ ```
107
+
108
+ `download` resolves as soon as the task is registered; progress and the final
109
+ result arrive through the task's listeners. Multiple downloads can run at the
110
+ same time — every event carries a `taskId`.
111
+
112
+ ### Cancel
113
+
114
+ ```ts
115
+ await task.cancel();
116
+ ```
117
+
118
+ Cancellation calls the native cancellation path and aborts the underlying
119
+ yt-dlp download. To cancel a task after your JS task object is gone:
120
+
121
+ ```ts
122
+ await YtDlp.cancel(taskId);
123
+ ```
124
+
125
+ ### Version
126
+
127
+ ```ts
128
+ const version = await YtDlp.getVersion();
129
+ // { ytDlp: '2026.xx.xx', library: '0.1.0' }
130
+ ```
131
+
132
+ The embedded yt-dlp version and the npm package version are independent.
133
+
134
+ ## Supported download options
135
+
136
+ | Option | Description |
137
+ | --- | --- |
138
+ | `url` | Required. Any URL supported by yt-dlp. |
139
+ | `format` | Raw yt-dlp format expression, e.g. `best`, `bestaudio`, `best[height<=720]`. |
140
+ | `output.directory` | Subdirectory under the app's `yt-dlp` folder. Sanitized. |
141
+ | `output.filename` | yt-dlp output template, e.g. `%(title)s.%(ext)s`. Sanitized. |
142
+ | `headers` | Extra HTTP headers, e.g. `{ Referer: '...' }`. |
143
+ | `userAgent` | Custom `User-Agent`. |
144
+ | `referer` | Custom `Referer`. |
145
+ | `proxy` | Proxy URL. |
146
+ | `cookies.path` | Path to a Netscape-format cookies file. |
147
+ | `playlist.enabled` | Default `false` — a playlist URL downloads only the first item unless enabled. |
148
+ | `playlist.start` / `playlist.end` | Playlist item range (1-based). |
149
+ | `subtitles.enabled` | Write subtitles. |
150
+ | `subtitles.languages` | e.g. `['en', 'bn']`. |
151
+ | `subtitles.autoGenerated` | Also write auto-generated subtitles. |
152
+ | `network.timeout` | Socket timeout in seconds. |
153
+ | `network.retries` | Number of retries. |
154
+
155
+ ### Not supported (yet)
156
+
157
+ The bundled yt-dlp-android build ships no FFmpeg, so the following are
158
+ rejected up front with `PROCESSING_FAILED` rather than silently ignored:
159
+
160
+ - `merge: true` (e.g. `bestvideo+bestaudio` will fail because merging requires FFmpeg)
161
+ - `audio.*` (audio extraction / re-encoding)
162
+ - `metadata` (embedding)
163
+ - `thumbnail` (embedding)
164
+
165
+ Use `format: 'best'` or `format: 'bestaudio'` and download a single stream
166
+ that requires no post-processing.
167
+
168
+ ## Download task
169
+
170
+ A `DownloadTask` exposes:
171
+
172
+ - `id: string`
173
+ - `cancel(): Promise<void>`
174
+ - `getStatus(): Promise<DownloadStatus>`
175
+ - `getProgress(): Promise<DownloadProgress | null>`
176
+ - `addListener(event, listener): Subscription`
177
+
178
+ Statuses: `queued | extracting | downloading | processing | completed |
179
+ cancelled | failed`.
180
+
181
+ Events:
182
+
183
+ - `progress` → `DownloadProgress` (`percent`, `downloadedBytes`, `totalBytes`,
184
+ `speedBytesPerSecond`, `etaSeconds`, `filename`, `phase`)
185
+ - `state` → `{ taskId, status }`
186
+ - `completed` → `DownloadResult` (`taskId`, `path`, `filename`, `size`)
187
+ - `error` → `YtDlpError`
188
+
189
+ Progress events are throttled to ~200 ms and numeric fields are `undefined`
190
+ when the value is unknown (never `NaN`).
191
+
192
+ ## Errors
193
+
194
+ All failures normalize to `YtDlpError` with a `code`:
195
+
196
+ `INVALID_URL`, `EXTRACTION_FAILED`, `DOWNLOAD_FAILED`, `CANCELLED`,
197
+ `FORMAT_UNAVAILABLE`, `NETWORK_ERROR`, `AUTHENTICATION_REQUIRED`,
198
+ `GEO_RESTRICTED`, `PRIVATE_CONTENT`, `AGE_RESTRICTED`, `PROCESSING_FAILED`,
199
+ `STORAGE_ERROR`, `INIT_FAILED`, `UNSUPPORTED_PLATFORM`, `UNKNOWN`.
200
+
201
+ ```ts
202
+ import { YtDlpError } from 'ytdlp-react-native';
203
+
204
+ try {
205
+ await YtDlp.extractInfo(url);
206
+ } catch (error) {
207
+ if (error instanceof YtDlpError) {
208
+ console.log(error.code, error.message);
209
+ }
210
+ }
211
+ ```
212
+
213
+ Raw native stack traces are never surfaced to the user.
214
+
215
+ ## Storage
216
+
217
+ Files are written to app-specific external storage:
218
+
219
+ ```
220
+ Android/data/<your-package>/files/yt-dlp/<output.directory>/...
221
+ ```
222
+
223
+ Filenames and directory segments are sanitized against illegal characters,
224
+ path traversal, excessive length and empty names. This avoids dangerous
225
+ permissions like `MANAGE_EXTERNAL_STORAGE`. The returned `DownloadResult.path`
226
+ is an absolute path inside your app's own storage.
227
+
228
+ ## Limitations
229
+
230
+ - Android only.
231
+ - No FFmpeg-based post-processing (see "Not supported" above).
232
+ - No background/foreground service: downloads pause if the JS/native runtime
233
+ is destroyed. Persisting task IDs lets you re-issue cancellation later.
234
+ - yt-dlp site support changes frequently. Not every website works forever,
235
+ and not every site provides every field.
236
+ - This package does **not** bundle or provide a way to update the embedded
237
+ yt-dlp at runtime.
238
+ - Playlists are opt-in via `playlist.enabled` to avoid accidental bulk
239
+ downloads.
240
+
241
+ ## Legal & responsible use
242
+
243
+ `ytdlp-react-native` is a technical wrapper around yt-dlp. It does not circumvent
244
+ DRM (Widevine, FairPlay, PlayReady, ...), bypass authentication, or access
245
+ private or unauthorized content — content that requires DRM or authentication
246
+ will fail with an error.
247
+
248
+ You are responsible for complying with:
249
+
250
+ - website terms of service
251
+ - copyright law and content licenses
252
+ - authentication rules
253
+ - platform policies
254
+
255
+ Do not use this library to download content you do not have the right to
256
+ download.
257
+
258
+ ## Third-party licenses
259
+
260
+ | Component | License |
261
+ | --- | --- |
262
+ | `ytdlp-react-native` (this package) | MIT |
263
+ | `yt-dlp-android` (Maven `dev.ffmpegkit-maintained:yt-dlp-android`) | MIT |
264
+ | [yt-dlp](https://github.com/yt-dlp/yt-dlp) | Unlicense |
265
+ | [Chaquopy](https://chaquo.com/chaquopy/) | BSD-style (per the embedded distribution) |
266
+
267
+ Re-verify third-party licenses at release time.
@@ -0,0 +1,25 @@
1
+ plugins {
2
+ id 'com.android.library'
3
+ id 'expo-module-gradle-plugin'
4
+ }
5
+
6
+ group = 'expo.modules.ytdlp'
7
+ version = '0.1.0'
8
+
9
+ android {
10
+ namespace "expo.modules.ytdlp"
11
+ compileSdk 35
12
+ defaultConfig {
13
+ minSdk 24
14
+ versionCode 1
15
+ versionName "0.1.0"
16
+ }
17
+ lintOptions {
18
+ abortOnError false
19
+ }
20
+ }
21
+
22
+ dependencies {
23
+ // yt-dlp embedded via Chaquopy. Pinned exact version (see AGENTS.md §59).
24
+ implementation 'dev.ffmpegkit-maintained:yt-dlp-android:2.0.2'
25
+ }
@@ -0,0 +1,2 @@
1
+ <manifest>
2
+ </manifest>
@@ -0,0 +1,74 @@
1
+ package expo.modules.ytdlp
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import expo.modules.kotlin.modules.Module
6
+ import expo.modules.kotlin.modules.ModuleDefinition
7
+
8
+ class ExpoYtDlpModule : Module() {
9
+
10
+ private var downloadManager: YtDlpDownloadManager? = null
11
+
12
+ override fun definition() = ModuleDefinition {
13
+ Name("ExpoYtDlp")
14
+
15
+ Events("downloadEvent")
16
+
17
+ AsyncFunction("getVersion") {
18
+ runCatching {
19
+ YtDlpEngine.ensureInitialized(requireContext())
20
+ YtDlpEngine.getYtDlpVersion()
21
+ }.getOrElse { failure ->
22
+ Log.w(LOG_TAG, "getVersion failed", failure)
23
+ throw failure
24
+ }
25
+ }
26
+
27
+ AsyncFunction("extractInfo") { url: String, options: Map<String, Any?>? ->
28
+ runCatching {
29
+ YtDlpEngine.ensureInitialized(requireContext())
30
+ YtDlpEngine.extractInfoJson(url, options)
31
+ }.getOrElse { failure ->
32
+ Log.w(LOG_TAG, "extractInfo failed", failure)
33
+ throw failure
34
+ }
35
+ }
36
+
37
+ AsyncFunction("startDownload") { options: Map<String, Any?> ->
38
+ runCatching {
39
+ manager().start(options)
40
+ }.getOrElse { failure ->
41
+ Log.w(LOG_TAG, "startDownload failed", failure)
42
+ throw failure
43
+ }
44
+ }
45
+
46
+ Function("cancelDownload") { taskId: String ->
47
+ manager().cancel(taskId)
48
+ }
49
+
50
+ Function("getDownloadStatus") { taskId: String ->
51
+ manager().statusOf(taskId)
52
+ }
53
+
54
+ OnDestroy {
55
+ downloadManager?.shutdown()
56
+ downloadManager = null
57
+ }
58
+ }
59
+
60
+ private fun manager(): YtDlpDownloadManager {
61
+ downloadManager?.let { return it }
62
+ return YtDlpDownloadManager(requireContext()) { payload -> sendEvent("downloadEvent", payload) }
63
+ .also { downloadManager = it }
64
+ }
65
+
66
+ private fun requireContext(): Context {
67
+ return appContext.reactContext
68
+ ?: throw YtDlpNativeException("INIT_FAILED", "The React Native context is not available.")
69
+ }
70
+
71
+ private companion object {
72
+ const val LOG_TAG = "ExpoYtDlp"
73
+ }
74
+ }
@@ -0,0 +1,97 @@
1
+ package expo.modules.ytdlp
2
+
3
+ import android.content.Context
4
+ import java.io.File
5
+ import java.util.UUID
6
+ import java.util.concurrent.ConcurrentHashMap
7
+ import java.util.concurrent.ExecutorService
8
+ import java.util.concurrent.Executors
9
+
10
+ /**
11
+ * Owns the concurrent download task registry (AGENTS.md §21) and runs each
12
+ * task on its own background thread so the module call thread is never
13
+ * blocked for the duration of a download (AGENTS.md §39).
14
+ */
15
+ internal class YtDlpDownloadManager(
16
+ private val context: Context,
17
+ private val onEvent: (Map<String, Any>) -> Unit,
18
+ ) {
19
+
20
+ private val tasks = ConcurrentHashMap<String, YtDlpTask>()
21
+ private val executor: ExecutorService = Executors.newCachedThreadPool()
22
+
23
+ /** Creates, registers and starts a download. Returns the public task info. */
24
+ fun start(options: Map<String, Any?>): Map<String, Any> {
25
+ YtDlpEngine.ensureInitialized(context)
26
+
27
+ val url = (options["url"] as? String)?.trim().takeIf { !it.isNullOrBlank() }
28
+ ?: throw YtDlpNativeException("INVALID_URL", "URL is required.")
29
+
30
+ val output = options["output"] as? Map<*, *>
31
+ val requestedDirectory = output?.get("directory") as? String
32
+ val baseDir = File(context.getExternalFilesDir(null) ?: context.filesDir, SUB_DIRECTORY)
33
+ val outputDirectory = YtDlpFileUtil.resolveOutputDirectory(baseDir, requestedDirectory)
34
+
35
+ val task = YtDlpTask(
36
+ id = UUID.randomUUID().toString(),
37
+ outputDirectory = outputDirectory,
38
+ onEvent = onEvent,
39
+ )
40
+
41
+ tasks[task.id] = task
42
+ task.setStatus(YtDlpStatus.EXTRACTING)
43
+ task.emitState()
44
+ executor.execute { runTask(task, options, outputDirectory) }
45
+ return mapOf("taskId" to task.id, "directory" to outputDirectory.absolutePath)
46
+ }
47
+
48
+ fun cancel(taskId: String): Boolean {
49
+ val task = tasks[taskId] ?: return false
50
+ return task.requestCancel()
51
+ }
52
+
53
+ fun statusOf(taskId: String): Map<String, Any?>? {
54
+ val task = tasks[taskId] ?: return null
55
+ return mapOf<String, Any?>(
56
+ "taskId" to task.id,
57
+ "status" to task.status.name.lowercase(),
58
+ "percent" to task.snapshot.percent,
59
+ "downloadedBytes" to task.snapshot.downloadedBytes,
60
+ "totalBytes" to task.snapshot.totalBytes,
61
+ "speedBytesPerSecond" to task.snapshot.speedBytesPerSecond,
62
+ "etaSeconds" to task.snapshot.etaSeconds,
63
+ "filename" to task.snapshot.filename,
64
+ )
65
+ }
66
+
67
+ fun shutdown() {
68
+ executor.shutdownNow()
69
+ }
70
+
71
+ private fun runTask(task: YtDlpTask, options: Map<String, Any?>, outputDirectory: File) {
72
+ try {
73
+ YtDlpEngine.executeDownload(task, options, outputDirectory)
74
+ val file = YtDlpFileUtil.findNewestFile(outputDirectory, task.startTime)
75
+ val secured = file?.let { YtDlpFileUtil.ensureContained(it, outputDirectory) }
76
+ task.emitCompleted(secured)
77
+ } catch (e: YtDlpNativeException) {
78
+ if (task.isCancelRequested() || e.errorCode == "CANCELLED") {
79
+ task.emitCancelled()
80
+ } else {
81
+ task.emitError(e.errorCode, e.message ?: "Download failed")
82
+ }
83
+ } catch (e: Throwable) {
84
+ if (task.isCancelRequested()) {
85
+ task.emitCancelled()
86
+ } else {
87
+ task.emitError("DOWNLOAD_FAILED", e.message ?: "Download failed")
88
+ }
89
+ } finally {
90
+ tasks.remove(task.id)
91
+ }
92
+ }
93
+
94
+ private companion object {
95
+ const val SUB_DIRECTORY = "yt-dlp"
96
+ }
97
+ }