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
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,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
|
+
}
|