expo-yt-dlp 0.1.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 (139) hide show
  1. package/.gitattributes +7 -0
  2. package/.prettierrc +8 -0
  3. package/LICENSE +21 -0
  4. package/README.md +521 -0
  5. package/android/build.gradle +18 -0
  6. package/android/src/main/AndroidManifest.xml +3 -0
  7. package/android/src/main/assets/expo-yt-dlp/licenses/bzip2.txt +42 -0
  8. package/android/src/main/assets/expo-yt-dlp/licenses/cpython.txt +277 -0
  9. package/android/src/main/assets/expo-yt-dlp/licenses/libffi.txt +21 -0
  10. package/android/src/main/assets/expo-yt-dlp/licenses/openssl.txt +177 -0
  11. package/android/src/main/assets/expo-yt-dlp/licenses/quickjs-ng.txt +24 -0
  12. package/android/src/main/assets/expo-yt-dlp/licenses/xz.txt +77 -0
  13. package/android/src/main/assets/expo-yt-dlp/manifest.json +57 -0
  14. package/android/src/main/assets/expo-yt-dlp/python-stdlib.zip +0 -0
  15. package/android/src/main/assets/expo-yt-dlp/site-packages.zip +0 -0
  16. package/android/src/main/java/expo/modules/ytdlp/ExpoYtDlpModule.kt +270 -0
  17. package/android/src/main/java/expo/modules/ytdlp/NativeMuxer.kt +86 -0
  18. package/android/src/main/java/expo/modules/ytdlp/Records.kt +36 -0
  19. package/android/src/main/java/expo/modules/ytdlp/YtDlpProcess.kt +90 -0
  20. package/android/src/main/java/expo/modules/ytdlp/YtDlpRuntime.kt +193 -0
  21. package/android/src/main/jniLibs/arm64-v8a/libcrypto_python.so +0 -0
  22. package/android/src/main/jniLibs/arm64-v8a/libpymod__asyncio.so +0 -0
  23. package/android/src/main/jniLibs/arm64-v8a/libpymod__bisect.so +0 -0
  24. package/android/src/main/jniLibs/arm64-v8a/libpymod__blake2.so +0 -0
  25. package/android/src/main/jniLibs/arm64-v8a/libpymod__bz2.so +0 -0
  26. package/android/src/main/jniLibs/arm64-v8a/libpymod__csv.so +0 -0
  27. package/android/src/main/jniLibs/arm64-v8a/libpymod__ctypes.so +0 -0
  28. package/android/src/main/jniLibs/arm64-v8a/libpymod__decimal.so +0 -0
  29. package/android/src/main/jniLibs/arm64-v8a/libpymod__elementtree.so +0 -0
  30. package/android/src/main/jniLibs/arm64-v8a/libpymod__hashlib.so +0 -0
  31. package/android/src/main/jniLibs/arm64-v8a/libpymod__heapq.so +0 -0
  32. package/android/src/main/jniLibs/arm64-v8a/libpymod__hmac.so +0 -0
  33. package/android/src/main/jniLibs/arm64-v8a/libpymod__json.so +0 -0
  34. package/android/src/main/jniLibs/arm64-v8a/libpymod__lzma.so +0 -0
  35. package/android/src/main/jniLibs/arm64-v8a/libpymod__md5.so +0 -0
  36. package/android/src/main/jniLibs/arm64-v8a/libpymod__pickle.so +0 -0
  37. package/android/src/main/jniLibs/arm64-v8a/libpymod__posixsubprocess.so +0 -0
  38. package/android/src/main/jniLibs/arm64-v8a/libpymod__queue.so +0 -0
  39. package/android/src/main/jniLibs/arm64-v8a/libpymod__random.so +0 -0
  40. package/android/src/main/jniLibs/arm64-v8a/libpymod__sha1.so +0 -0
  41. package/android/src/main/jniLibs/arm64-v8a/libpymod__sha2.so +0 -0
  42. package/android/src/main/jniLibs/arm64-v8a/libpymod__sha3.so +0 -0
  43. package/android/src/main/jniLibs/arm64-v8a/libpymod__socket.so +0 -0
  44. package/android/src/main/jniLibs/arm64-v8a/libpymod__ssl.so +0 -0
  45. package/android/src/main/jniLibs/arm64-v8a/libpymod__statistics.so +0 -0
  46. package/android/src/main/jniLibs/arm64-v8a/libpymod__struct.so +0 -0
  47. package/android/src/main/jniLibs/arm64-v8a/libpymod__zoneinfo.so +0 -0
  48. package/android/src/main/jniLibs/arm64-v8a/libpymod_array.so +0 -0
  49. package/android/src/main/jniLibs/arm64-v8a/libpymod_binascii.so +0 -0
  50. package/android/src/main/jniLibs/arm64-v8a/libpymod_cmath.so +0 -0
  51. package/android/src/main/jniLibs/arm64-v8a/libpymod_fcntl.so +0 -0
  52. package/android/src/main/jniLibs/arm64-v8a/libpymod_math.so +0 -0
  53. package/android/src/main/jniLibs/arm64-v8a/libpymod_mmap.so +0 -0
  54. package/android/src/main/jniLibs/arm64-v8a/libpymod_pyexpat.so +0 -0
  55. package/android/src/main/jniLibs/arm64-v8a/libpymod_resource.so +0 -0
  56. package/android/src/main/jniLibs/arm64-v8a/libpymod_select.so +0 -0
  57. package/android/src/main/jniLibs/arm64-v8a/libpymod_syslog.so +0 -0
  58. package/android/src/main/jniLibs/arm64-v8a/libpymod_termios.so +0 -0
  59. package/android/src/main/jniLibs/arm64-v8a/libpymod_unicodedata.so +0 -0
  60. package/android/src/main/jniLibs/arm64-v8a/libpymod_zlib.so +0 -0
  61. package/android/src/main/jniLibs/arm64-v8a/libpython3.14.so +0 -0
  62. package/android/src/main/jniLibs/arm64-v8a/libqjs.so +0 -0
  63. package/android/src/main/jniLibs/arm64-v8a/libssl_python.so +0 -0
  64. package/android/src/main/jniLibs/arm64-v8a/libytdlp_python.so +0 -0
  65. package/android/src/main/jniLibs/x86_64/libcrypto_python.so +0 -0
  66. package/android/src/main/jniLibs/x86_64/libpymod__asyncio.so +0 -0
  67. package/android/src/main/jniLibs/x86_64/libpymod__bisect.so +0 -0
  68. package/android/src/main/jniLibs/x86_64/libpymod__blake2.so +0 -0
  69. package/android/src/main/jniLibs/x86_64/libpymod__bz2.so +0 -0
  70. package/android/src/main/jniLibs/x86_64/libpymod__csv.so +0 -0
  71. package/android/src/main/jniLibs/x86_64/libpymod__ctypes.so +0 -0
  72. package/android/src/main/jniLibs/x86_64/libpymod__decimal.so +0 -0
  73. package/android/src/main/jniLibs/x86_64/libpymod__elementtree.so +0 -0
  74. package/android/src/main/jniLibs/x86_64/libpymod__hashlib.so +0 -0
  75. package/android/src/main/jniLibs/x86_64/libpymod__heapq.so +0 -0
  76. package/android/src/main/jniLibs/x86_64/libpymod__hmac.so +0 -0
  77. package/android/src/main/jniLibs/x86_64/libpymod__json.so +0 -0
  78. package/android/src/main/jniLibs/x86_64/libpymod__lzma.so +0 -0
  79. package/android/src/main/jniLibs/x86_64/libpymod__md5.so +0 -0
  80. package/android/src/main/jniLibs/x86_64/libpymod__pickle.so +0 -0
  81. package/android/src/main/jniLibs/x86_64/libpymod__posixsubprocess.so +0 -0
  82. package/android/src/main/jniLibs/x86_64/libpymod__queue.so +0 -0
  83. package/android/src/main/jniLibs/x86_64/libpymod__random.so +0 -0
  84. package/android/src/main/jniLibs/x86_64/libpymod__sha1.so +0 -0
  85. package/android/src/main/jniLibs/x86_64/libpymod__sha2.so +0 -0
  86. package/android/src/main/jniLibs/x86_64/libpymod__sha3.so +0 -0
  87. package/android/src/main/jniLibs/x86_64/libpymod__socket.so +0 -0
  88. package/android/src/main/jniLibs/x86_64/libpymod__ssl.so +0 -0
  89. package/android/src/main/jniLibs/x86_64/libpymod__statistics.so +0 -0
  90. package/android/src/main/jniLibs/x86_64/libpymod__struct.so +0 -0
  91. package/android/src/main/jniLibs/x86_64/libpymod__zoneinfo.so +0 -0
  92. package/android/src/main/jniLibs/x86_64/libpymod_array.so +0 -0
  93. package/android/src/main/jniLibs/x86_64/libpymod_binascii.so +0 -0
  94. package/android/src/main/jniLibs/x86_64/libpymod_cmath.so +0 -0
  95. package/android/src/main/jniLibs/x86_64/libpymod_fcntl.so +0 -0
  96. package/android/src/main/jniLibs/x86_64/libpymod_math.so +0 -0
  97. package/android/src/main/jniLibs/x86_64/libpymod_mmap.so +0 -0
  98. package/android/src/main/jniLibs/x86_64/libpymod_pyexpat.so +0 -0
  99. package/android/src/main/jniLibs/x86_64/libpymod_resource.so +0 -0
  100. package/android/src/main/jniLibs/x86_64/libpymod_select.so +0 -0
  101. package/android/src/main/jniLibs/x86_64/libpymod_syslog.so +0 -0
  102. package/android/src/main/jniLibs/x86_64/libpymod_termios.so +0 -0
  103. package/android/src/main/jniLibs/x86_64/libpymod_unicodedata.so +0 -0
  104. package/android/src/main/jniLibs/x86_64/libpymod_zlib.so +0 -0
  105. package/android/src/main/jniLibs/x86_64/libpython3.14.so +0 -0
  106. package/android/src/main/jniLibs/x86_64/libqjs.so +0 -0
  107. package/android/src/main/jniLibs/x86_64/libssl_python.so +0 -0
  108. package/android/src/main/jniLibs/x86_64/libytdlp_python.so +0 -0
  109. package/app.plugin.js +32 -0
  110. package/build/ExpoYtDlp.types.d.ts +273 -0
  111. package/build/ExpoYtDlp.types.d.ts.map +1 -0
  112. package/build/ExpoYtDlp.types.js +5 -0
  113. package/build/ExpoYtDlp.types.js.map +1 -0
  114. package/build/ExpoYtDlpModule.d.ts +38 -0
  115. package/build/ExpoYtDlpModule.d.ts.map +1 -0
  116. package/build/ExpoYtDlpModule.js +4 -0
  117. package/build/ExpoYtDlpModule.js.map +1 -0
  118. package/build/helpers.d.ts +10 -0
  119. package/build/helpers.d.ts.map +1 -0
  120. package/build/helpers.js +73 -0
  121. package/build/helpers.js.map +1 -0
  122. package/build/index.d.ts +52 -0
  123. package/build/index.d.ts.map +1 -0
  124. package/build/index.js +184 -0
  125. package/build/index.js.map +1 -0
  126. package/build/options.d.ts +9 -0
  127. package/build/options.d.ts.map +1 -0
  128. package/build/options.js +46 -0
  129. package/build/options.js.map +1 -0
  130. package/eslint.config.cjs +5 -0
  131. package/expo-module.config.json +6 -0
  132. package/package.json +67 -0
  133. package/runtime-versions.json +37 -0
  134. package/src/ExpoYtDlp.types.ts +288 -0
  135. package/src/ExpoYtDlpModule.ts +39 -0
  136. package/src/helpers.ts +88 -0
  137. package/src/index.ts +252 -0
  138. package/src/options.ts +48 -0
  139. package/tsconfig.json +28 -0
package/.gitattributes ADDED
@@ -0,0 +1,7 @@
1
+ * text=auto eol=lf
2
+ *.png binary
3
+ *.jpg binary
4
+ *.jar binary
5
+ *.keystore binary
6
+ *.so binary
7
+ *.zip binary
package/.prettierrc ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "printWidth": 100,
3
+ "tabWidth": 2,
4
+ "singleQuote": true,
5
+ "bracketSameLine": true,
6
+ "trailingComma": "es5",
7
+ "jsxSingleQuote": false,
8
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
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,521 @@
1
+ # expo-yt-dlp
2
+
3
+ [yt-dlp](https://github.com/yt-dlp/yt-dlp) inside your Expo / React Native app. Get video
4
+ info, check sizes, download video, audio, subtitles and thumbnails, whole playlists or batches
5
+ of links, all on the device, with no server.
6
+
7
+ The package bundles its own stripped-down Python 3.14 runtime and a copy of yt-dlp cut down to
8
+ the sites you need, and wraps them in a typed JS API. Anything the wrapper doesn't cover is one
9
+ `exec([...])` away, because it's the real yt-dlp underneath.
10
+
11
+ | | |
12
+ | --- | --- |
13
+ | **Platforms** | Android (arm64-v8a, x86_64). iOS is planned. |
14
+ | **Sites in this build** | YouTube, Facebook, Instagram, plus yt-dlp's generic extractor (pages with plain `<video>` tags) |
15
+ | **Size** | About 11 MB added to a single-ABI APK; about 28 MB on the device once extracted |
16
+ | **Requires** | A development build (`npx expo run:android` or EAS Build). It does not work in Expo Go. |
17
+
18
+ - [Install](#install)
19
+ - [Quick start](#quick-start)
20
+ - [API](#api)
21
+ - [Recipes](#recipes)
22
+ - [How it works](#how-it-works)
23
+ - [Maintaining the package](#maintaining-the-package)
24
+ - [Limitations](#limitations)
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ npx expo install expo-yt-dlp
30
+ ```
31
+
32
+ Add the config plugin to `app.json`. It is **required**: the bundled binaries have to be
33
+ extracted to disk to run, and the plugin turns that on.
34
+
35
+ ```json
36
+ {
37
+ "expo": {
38
+ "plugins": [["expo-yt-dlp", { "abiFilters": ["arm64-v8a", "x86_64"] }]]
39
+ }
40
+ }
41
+ ```
42
+
43
+ `abiFilters` is optional. The runtime only exists for 64-bit ABIs, so limiting the build to
44
+ them also drops 32-bit copies of your other native libraries. Then rebuild the native app with
45
+ `npx expo prebuild` and `npx expo run:android`, or with EAS Build.
46
+
47
+ ## Quick start
48
+
49
+ ```ts
50
+ import * as YtDlp from 'expo-yt-dlp';
51
+
52
+ async function quickStart() {
53
+ if (!YtDlp.isSupported()) return; // iOS/web, 32-bit phones, or the plugin is missing
54
+
55
+ const url = 'https://www.youtube.com/watch?v=jNQXAC9IVRw';
56
+
57
+ const info = await YtDlp.getInfo(url);
58
+ console.log(info.title, info.duration);
59
+
60
+ const task = YtDlp.download(url, {
61
+ maxHeight: 720,
62
+ onProgress: (p) => console.log(p.status, p.percent?.toFixed(0), '%'),
63
+ });
64
+ const { files } = await task.promise; // absolute paths, e.g. [".../files/downloads/Me at the zoo [jNQXAC9IVRw].mp4"]
65
+ console.log(files);
66
+ }
67
+ ```
68
+
69
+ The first call takes a second or two longer while the runtime is unpacked, which happens once
70
+ per app version. To do that at startup instead, call `YtDlp.prepare()`.
71
+
72
+ ## API
73
+
74
+ Every function takes a URL from any supported site. All options are optional.
75
+
76
+ ### Setup
77
+
78
+ | Function | Returns | |
79
+ | --- | --- | --- |
80
+ | `isSupported()` | `boolean` | Whether the runtime can run on this device and build. Synchronous. |
81
+ | `prepare()` | `Promise<RuntimeInfo>` | Unpacks the runtime ahead of time. Returns the bundled versions of Python, yt-dlp and QuickJS, whether ffmpeg is bundled, the ABI, and the extractor list. |
82
+
83
+ ### Looking things up (nothing is downloaded)
84
+
85
+ | Function | Returns | |
86
+ | --- | --- | --- |
87
+ | `getInfo(url, options?)` | `Promise<VideoInfo>` | yt-dlp's full info dict: title, duration, uploader, formats, subtitles, thumbnails… |
88
+ | `getFormats(urlOrInfo, options?)` | `Promise<FormatSummary[]>` | Every format, best first: resolution, codecs, bitrate, size. |
89
+ | `estimateDownload(url, options?)` | `Promise<DownloadEstimate>` | Exactly what `download` would fetch with the same options, including files and sizes, without downloading. |
90
+ | `getSubtitles(urlOrInfo, options?)` | `Promise<SubtitleTrack[]>` | Uploaded subtitles, then auto-generated captions. |
91
+ | `getPlaylist(url, { limit? })` | `Promise<Playlist>` | Entries of a playlist or channel, without resolving each video. |
92
+ | `search(query, { limit? })` | `Promise<PlaylistEntry[]>` | YouTube search results (default 10). |
93
+
94
+ `getFormats` and `getSubtitles` also accept an info dict you already fetched, which saves a
95
+ network round trip:
96
+
97
+ ```ts
98
+ import * as YtDlp from 'expo-yt-dlp';
99
+
100
+ async function inspect(url: string) {
101
+ const info = await YtDlp.getInfo(url);
102
+ const formats = await YtDlp.getFormats(info);
103
+ const subs = await YtDlp.getSubtitles(info);
104
+ console.log(formats[0].resolution, formats[0].size, subs.map((s) => s.language));
105
+ }
106
+ ```
107
+
108
+ Options for lookups (`InfoOptions`):
109
+
110
+ | Option | Default | |
111
+ | --- | --- | --- |
112
+ | `playlist` | `false` | For `watch?v=…&list=…` URLs, resolve the playlist instead of just the video. |
113
+ | `flatPlaylist` | `false` | List playlist entries without resolving each one (fast). |
114
+ | `cookiesFile` | | Path to a Netscape-format cookies file, for content that needs a login. |
115
+ | `networkRetries` | `3` | Retries after dropped connections, timeouts and 5xx responses. |
116
+ | `extraArgs` | | Any extra yt-dlp CLI arguments. |
117
+ | `signal` | | An `AbortSignal` that cancels the lookup. |
118
+
119
+ ### Downloading
120
+
121
+ | Function | Returns | |
122
+ | --- | --- | --- |
123
+ | `download(url, options?)` | `DownloadTask` | Downloads media (plus subtitles/thumbnails if asked). |
124
+ | `downloadSubtitles(url, options?)` | `DownloadTask` | Only subtitle files. |
125
+ | `downloadThumbnail(url, options?)` | `DownloadTask` | Only the thumbnail image. |
126
+ | `downloadBatch(urls, options?)` | `BatchTask` | Several URLs, `concurrency` at a time; per-item results. |
127
+
128
+ A `DownloadTask` has an `id`, a `promise` that resolves to a `DownloadResult`, and `cancel()`.
129
+ A `DownloadResult` has `files` (all media paths) and `items` (one per video: id, title, files
130
+ with sizes and codecs, subtitle files, thumbnail files).
131
+
132
+ By default `download` gets the best H.264 video and AAC audio and merges them into one MP4.
133
+ These are the codecs every phone plays. Options (`DownloadOptions`):
134
+
135
+ | Option | Default | |
136
+ | --- | --- | --- |
137
+ | `outputDir` | `<filesDir>/downloads` | Where files go (`file://` URIs are accepted). |
138
+ | `maxHeight` | | Caps the resolution (short side, so `720` also works for portrait videos). |
139
+ | `audioOnly` | `false` | Best audio only, AAC `.m4a` where available. |
140
+ | `format` | | A yt-dlp format selector, e.g. `'18'` or `'bv*[height<=480]'`. Overrides the defaults above. |
141
+ | `playlist` | `false` | Download the whole playlist when the URL is (or includes) one. |
142
+ | `playlistItems` | | Which items: `'1:5'`, `'1,3,7'`, `'-3:'` (last three). Implies `playlist`. |
143
+ | `subtitles` | `false` | `true` for English (falling back to auto-captions), or `{ languages, auto, format }`. |
144
+ | `thumbnail` | `false` | Also save the thumbnail image. |
145
+ | `media` | `true` | `false` writes only subtitles/thumbnails. |
146
+ | `rateLimit` | | Max speed, e.g. `'500K'`, `'2M'`. |
147
+ | `cookiesFile`, `networkRetries`, `extraArgs` | | As for lookups. |
148
+ | `onProgress` | | Called with `DownloadProgress` events. |
149
+
150
+ **Progress events** (`DownloadProgress`) have a `status`:
151
+ - `downloading`: includes `percent`, `downloadedBytes`, `totalBytes`, `speed` (bytes/s) and `eta` (s). Video and audio are separate streams, so each reports its own progress, identified by `formatId`.
152
+ - `finished`: one stream is done.
153
+ - `merging`: video and audio are being combined.
154
+ - `retrying`: includes `attempt`, `retries` and `retryDelay`.
155
+
156
+ **Errors.** Rejections carry a `code`:
157
+
158
+ | Code | Meaning |
159
+ | --- | --- |
160
+ | `ERR_YTDLP` | yt-dlp failed. The message is yt-dlp's `ERROR:` line (unavailable video, login required, bot check…). |
161
+ | `ERR_YTDLP_CANCELLED` | You called `cancel()`, or an `AbortSignal` fired. Partial files are deleted. |
162
+ | `ERR_YTDLP_UNSUPPORTED` | This device or build can't run the runtime. The message says why (for example, a missing config plugin). |
163
+
164
+ ### Running any yt-dlp command
165
+
166
+ `exec(args)` runs yt-dlp with raw CLI arguments and returns `{ exitCode, stdout, stderr }`.
167
+ The runtime adds its own flags first (JS runtime, cache dir, `--ignore-config`), so write
168
+ commands exactly as you would in a terminal.
169
+
170
+ ```ts
171
+ import * as YtDlp from 'expo-yt-dlp';
172
+
173
+ async function rawCommands(url: string) {
174
+ // yt-dlp's own format table
175
+ console.log((await YtDlp.exec(['-F', url])).stdout);
176
+
177
+ // Custom fields, one line per video
178
+ const r = await YtDlp.exec(['--print', '%(title)s | %(duration_string)s | %(view_count)s', url]);
179
+ console.log(r.stdout);
180
+
181
+ // Channel's latest uploads as JSON lines
182
+ await YtDlp.exec(['--flat-playlist', '-I', '1:20', '-j', 'https://www.youtube.com/@blender/videos']);
183
+ }
184
+ ```
185
+
186
+ See yt-dlp's [options](https://github.com/yt-dlp/yt-dlp#usage-and-options) and
187
+ [output template](https://github.com/yt-dlp/yt-dlp#output-template) docs. `extraArgs` on the
188
+ other functions accepts the same flags.
189
+
190
+ ## Recipes
191
+
192
+ These examples use `import * as YtDlp from 'expo-yt-dlp'`.
193
+
194
+ ### Check the size before downloading
195
+
196
+ ```ts
197
+ import * as YtDlp from 'expo-yt-dlp';
198
+
199
+ async function downloadIfSmall(url: string, limitBytes = 50_000_000) {
200
+ const plan = await YtDlp.estimateDownload(url, { maxHeight: 720 });
201
+ // totalSize is null when the site doesn't report sizes (e.g. some Facebook videos)
202
+ if (plan.totalSize != null && plan.totalSize > limitBytes) {
203
+ throw new Error(`Too big: ${(plan.totalSize / 1e6).toFixed(0)} MB`);
204
+ }
205
+ return YtDlp.download(url, { maxHeight: 720 }).promise;
206
+ }
207
+ ```
208
+
209
+ To let users pick a quality, list the formats and pass the chosen id as `format`:
210
+
211
+ ```ts
212
+ import * as YtDlp from 'expo-yt-dlp';
213
+
214
+ async function pickQuality(url: string) {
215
+ const formats = await YtDlp.getFormats(url);
216
+ const choices = formats.filter((f) => f.hasVideo && f.vcodec?.startsWith('avc1'));
217
+ // e.g. [{ formatId: '137', resolution: '1920x1080', size: 48_000_000, ... }, ...]
218
+ const chosen = choices[0];
219
+ // Video-only formats have no sound: combine with the best audio using yt-dlp syntax.
220
+ return YtDlp.download(url, { format: `${chosen.formatId}+bestaudio[ext=m4a]` }).promise;
221
+ }
222
+ ```
223
+
224
+ Without a bundled ffmpeg, a `+` combination is merged on the device when Android's
225
+ `MediaMuxer` supports the pair: H.264 + AAC into `.mp4`, or VP8/VP9 + Opus/Vorbis into
226
+ `.webm`. Other pairs, such as VP9 + AAC, come back as two files: the video and the audio.
227
+
228
+ ### Audio only
229
+
230
+ ```ts
231
+ import * as YtDlp from 'expo-yt-dlp';
232
+
233
+ async function audio(url: string) {
234
+ const { files } = await YtDlp.download(url, { audioOnly: true }).promise; // .m4a (AAC)
235
+ return files[0];
236
+ }
237
+ ```
238
+
239
+ ### Subtitles
240
+
241
+ ```ts
242
+ import * as YtDlp from 'expo-yt-dlp';
243
+
244
+ async function subtitles(url: string) {
245
+ // What's available? Uploaded tracks first, then auto-generated ones.
246
+ const tracks = await YtDlp.getSubtitles(url);
247
+ console.log(tracks.filter((t) => !t.automatic).map((t) => t.language));
248
+
249
+ // Only the subtitle files:
250
+ const subs = await YtDlp.downloadSubtitles(url, { languages: ['en', 'es'], format: 'vtt' }).promise;
251
+ console.log(subs.items[0].subtitles); // [{ language: 'en', ext: 'vtt', path: '...' }]
252
+
253
+ // Or together with the video:
254
+ await YtDlp.download(url, { subtitles: { languages: ['en'] }, thumbnail: true }).promise;
255
+ }
256
+ ```
257
+
258
+ ### Playlists, channels and search
259
+
260
+ ```ts
261
+ import * as YtDlp from 'expo-yt-dlp';
262
+
263
+ async function playlists() {
264
+ const list = await YtDlp.getPlaylist('https://www.youtube.com/playlist?list=PL…', { limit: 50 });
265
+ console.log(list.title, list.entries.length);
266
+
267
+ // Download the first five items as one job…
268
+ await YtDlp.download('https://www.youtube.com/playlist?list=PL…', { playlistItems: '1:5' }).promise;
269
+
270
+ // …or search and pick
271
+ const results = await YtDlp.search('blender open movie', { limit: 5 });
272
+ await YtDlp.download(results[0].url).promise;
273
+ }
274
+ ```
275
+
276
+ YouTube **Mixes** (the auto-generated "radio" playlists) work through the watch URL that
277
+ YouTube gives you, `watch?v=ID&list=RD…`. They can run to dozens of songs, so always pass a
278
+ `limit` or `playlistItems`. Not every video has a Mix, and in that case you get the single
279
+ video back. A bare `playlist?list=RD…` URL doesn't work, because YouTube refuses to show
280
+ Mixes that way.
281
+
282
+ ```ts
283
+ import * as YtDlp from 'expo-yt-dlp';
284
+
285
+ async function mix(videoId: string) {
286
+ const url = `https://www.youtube.com/watch?v=${videoId}&list=RD${videoId}`;
287
+ const radio = await YtDlp.getPlaylist(url, { limit: 25 });
288
+ console.log(radio.title, radio.entries.map((e) => e.title));
289
+
290
+ // Plain download(url) fetches only the video; opt into the playlist to take songs from it
291
+ await YtDlp.download(url, { audioOnly: true, playlistItems: '1:10' }).promise;
292
+ }
293
+ ```
294
+
295
+ ### Batches
296
+
297
+ ```ts
298
+ import * as YtDlp from 'expo-yt-dlp';
299
+
300
+ async function batch(urls: string[]) {
301
+ const job = YtDlp.downloadBatch(urls, {
302
+ concurrency: 2, // each download is its own process; 2-3 is plenty on a phone
303
+ maxHeight: 720,
304
+ onProgress: (p, index) => console.log(`#${index}`, p.status, p.percent),
305
+ onItemDone: (r, index) => console.log(`#${index}`, r.ok ? 'done' : r.error.message),
306
+ });
307
+ // job.cancel() stops everything
308
+ const results = await job.promise; // never rejects; one entry per URL, in order
309
+ return results.filter((r) => r.ok);
310
+ }
311
+ ```
312
+
313
+ Items can override the shared options:
314
+ `downloadBatch([url1, { url: url2, options: { audioOnly: true } }])`.
315
+
316
+ ### Cancelling
317
+
318
+ ```ts
319
+ import * as YtDlp from 'expo-yt-dlp';
320
+
321
+ async function cancelling(url: string) {
322
+ const task = YtDlp.download(url);
323
+ setTimeout(() => task.cancel(), 5000);
324
+ try {
325
+ await task.promise;
326
+ } catch (e: any) {
327
+ if (e.code === 'ERR_YTDLP_CANCELLED') console.log('cancelled, partial files removed');
328
+ }
329
+
330
+ const controller = new AbortController();
331
+ const info = YtDlp.getInfo(url, { signal: controller.signal });
332
+ controller.abort();
333
+ await info.catch(() => {});
334
+ }
335
+ ```
336
+
337
+ ### Save to the gallery
338
+
339
+ Files land in app-private storage. To publish them, move them with
340
+ [`expo-media-library`](https://docs.expo.dev/versions/latest/sdk/media-library/) or
341
+ `expo-file-system`:
342
+
343
+ ```ts
344
+ import * as MediaLibrary from 'expo-media-library';
345
+ import * as YtDlp from 'expo-yt-dlp';
346
+
347
+ async function saveToGallery(url: string) {
348
+ const { files } = await YtDlp.download(url, { maxHeight: 1080 }).promise;
349
+ await MediaLibrary.requestPermissionsAsync();
350
+ await MediaLibrary.saveToLibraryAsync(`file://${files[0]}`);
351
+ }
352
+ ```
353
+
354
+ ### Logged-in content (Instagram, Facebook)
355
+
356
+ Many Instagram and some Facebook posts require a session. Export cookies in Netscape format
357
+ (for example with a "cookies.txt" browser extension), ship or download the file to the device,
358
+ and pass `cookiesFile`:
359
+
360
+ ```ts
361
+ import * as YtDlp from 'expo-yt-dlp';
362
+
363
+ async function withCookies(url: string, cookiesPath: string) {
364
+ return YtDlp.download(url, { cookiesFile: cookiesPath }).promise;
365
+ }
366
+ ```
367
+
368
+ ## How it works
369
+
370
+ ```
371
+ JS (src/index.ts) ─ options → yt-dlp arguments
372
+ └─ Expo module (Kotlin) ── spawns ──> libytdlp_python.so -m expo_yt_dlp_runner … (child process)
373
+ ├─ libpython3.14.so + stripped stdlib (python.org Android build)
374
+ ├─ yt-dlp via its Python API (python/expo_yt_dlp_runner.py)
375
+ ├─ spawns libqjs.so (QuickJS-ng: YouTube JS challenges)
376
+ └─ spawns libffmpeg.so (optional)
377
+ ```
378
+
379
+ - **Python.** python.org's official Android build of CPython ships only as an embeddable
380
+ `libpython`. [`native/python-launcher`](native/python-launcher/main.c) is a 10-line `main()`
381
+ linked against it, which gives yt-dlp a normal `python` to run under.
382
+ - **Processes.** Every call is a separate child process, so cancelling means killing that
383
+ process.
384
+ - **Why the binaries are named `lib*.so`.** Android only allows executing files from
385
+ `nativeLibraryDir`, and only extracts `lib*.so` files there. The launcher, QuickJS, ffmpeg
386
+ and the stdlib C extensions (`libpymod_*.so`, symlinked into `lib-dynload`) are all
387
+ packaged that way.
388
+ - **Pure-Python code.** The stdlib and yt-dlp are precompiled to `.pyc`, zipped into the APK's
389
+ assets, and unpacked into app storage on first use.
390
+ - **The driver.** [`expo_yt_dlp_runner.py`](python/expo_yt_dlp_runner.py) wraps yt-dlp's API.
391
+ It handles format selection for merging, JSON progress events, per-video results with sizes
392
+ and side files, and retries for transient network errors.
393
+ - **Merging.** The best qualities are separate video and audio streams. Without ffmpeg, the
394
+ driver picks an H.264 + AAC pair and Kotlin merges it with Android's `MediaMuxer` (a stream
395
+ copy, not a re-encode). If a video has no such pair, which is common on Facebook, the best
396
+ single file is used instead.
397
+
398
+ ## Maintaining the package
399
+
400
+ Everything the runtime is built from, including which sites are kept, is pinned in
401
+ [`runtime-versions.json`](runtime-versions.json). Built binaries are not committed; they're
402
+ produced by `npm run build:runtime` and published inside the npm tarball.
403
+
404
+ **Requirements:** Python 3.14 (the bundle is precompiled for it), Node 20+, and the Android SDK
405
+ with NDK r27+ and CMake. Set `ANDROID_HOME` / `ANDROID_NDK_HOME` if they aren't in the default
406
+ locations.
407
+
408
+ ### Updating yt-dlp (and the rest)
409
+
410
+ ```sh
411
+ npm run deps:check # pinned vs. latest upstream
412
+ npm run deps:update # newest stable yt-dlp + the yt-dlp-ejs it pins + certifi
413
+ npm run deps:update -- --channel nightly # yt-dlp nightly build
414
+ npm run deps:update -- --ytdlp 2026.8.19 # a specific version (also how to roll back)
415
+ npm run deps:update -- --all # also Python 3.14.x, QuickJS-ng, ffmpeg
416
+ ```
417
+
418
+ `deps:update` rewrites the pins with verified SHA-256 hashes and rebuilds the runtime,
419
+ re-trimming yt-dlp to the configured extractors. It then runs the offline tests. After it
420
+ succeeds:
421
+ 1. Run `npm run test:online` to try the three sites for real.
422
+ 2. Run `npm run build:runtime` for a full native build.
423
+ 3. Try the example app on a device.
424
+ 4. Bump the version and tag it (see Releasing).
425
+
426
+ The **Update bundled yt-dlp** workflow does the first part weekly and opens a PR when there's
427
+ a new version.
428
+
429
+ ### Changing which sites are included
430
+
431
+ Edit `"extractors"` in `runtime-versions.json`. The names are yt-dlp's extractor module names
432
+ (`yt_dlp/extractor/<name>.py`), e.g. `"tiktok"`, `"vimeo"`, `"twitter"`, `"reddit"`. The generic
433
+ extractor is always kept. Then run `npm run build:runtime && npm run test:runtime`; the build
434
+ fails if a name doesn't exist. Each site adds a little to the bundle, and removing the list
435
+ entirely isn't supported (all ~1,800 extractors would add several MB).
436
+
437
+ ### Building and testing
438
+
439
+ | Command | What it does |
440
+ | --- | --- |
441
+ | `npm run build:runtime` | Builds `android/src/main/jniLibs` and `assets/expo-yt-dlp`. `--python-only` skips the NDK builds. |
442
+ | `npm test` | Unit tests for the TypeScript layer (jest). |
443
+ | `npm run test:runtime` | Offline checks of the built bundle: the trimmed yt-dlp imports and runs on the stripped stdlib, and network retries behave (local test server). |
444
+ | `npm run test:online` | Resolves, plans and downloads from the real sites using the bundled runtime. Can fail for reasons outside this repo (bot checks, rate limits). |
445
+ | `python scripts/device_smoke_test.py -- -J <url>` | Pushes the runtime to a connected device or emulator over `adb` and runs yt-dlp there, without building an app. |
446
+ | `cd example && npx expo run:android` | The example app, which exercises every function. |
447
+
448
+ The optional ffmpeg (`scripts/build_ffmpeg_android.sh`, Linux/macOS) builds a mux-only
449
+ ffmpeg that the next `build:runtime` bundles. Without it, merging uses `MediaMuxer`.
450
+
451
+ ### CI
452
+
453
+ | Workflow | When | What |
454
+ | --- | --- | --- |
455
+ | **CI** (`ci.yml`) | Every push to `main`, every PR | Lint, types, unit tests; full runtime build on Linux with offline tests; checks the npm tarball's contents; on PRs, also a Gradle build of the example app. |
456
+ | **Update bundled yt-dlp** (`update-deps.yml`) | Mondays, or manually (stable/nightly) | `deps:update` plus tests, then a PR with the new pins. Needs *Settings → Actions → General → Allow GitHub Actions to create and approve pull requests*. |
457
+ | **Release** (`release.yml`) | Pushing a `v*` tag | Builds the runtime, runs the tests, publishes to npm with trusted publishing (no token stored), creates a GitHub release listing the bundled versions. |
458
+
459
+ ### Releasing
460
+
461
+ ```sh
462
+ npm version patch # or minor/major
463
+ git push --follow-tags # the Release workflow builds and publishes
464
+ ```
465
+
466
+ Publishing uses [npm trusted publishing](https://docs.npmjs.com/trusted-publishers): npm
467
+ trusts `release.yml` in this repository, so no npm token is stored in GitHub. npm only lets
468
+ you set that up for a package that already exists, so the very first version is published
469
+ by hand, once:
470
+
471
+ 1. Turn on two-factor authentication for your npm account.
472
+ 2. Build and publish from a clean checkout:
473
+ ```sh
474
+ npm login
475
+ npm ci
476
+ npm run build:runtime # both ABIs
477
+ npm run build
478
+ npm pack --dry-run # check the file list and size
479
+ npm publish --access public
480
+ ```
481
+ 3. On npmjs.com, open the package's **Settings → Trusted publishing**, choose GitHub Actions
482
+ and enter owner `nigelbasa`, repository `expo-yt-dlp`, workflow `release.yml`, and leave
483
+ the environment empty.
484
+ 4. Optionally, under **Publishing access**, require 2FA and disallow tokens, so only the
485
+ workflow can publish.
486
+
487
+ After that, every tag is published by the workflow. Provenance statements need a public
488
+ source repository; add `--provenance` to the publish step in `release.yml` once the repo is
489
+ public.
490
+
491
+ ## Limitations
492
+
493
+ - **Android only for now.** iOS needs an in-process interpreter, because iOS apps can't
494
+ spawn processes.
495
+ - **64-bit devices only.** python.org doesn't build 32-bit Android.
496
+ - **Downloads run while your app's process is alive.** Use a foreground service in your app
497
+ for long background downloads.
498
+ - **Without a bundled ffmpeg there's no transcoding.** That means no MP3 conversion and no SRT
499
+ from VTT, and merging needs an H.264 + AAC pair (the best-quality VP9/AV1 streams are
500
+ skipped).
501
+ - **Bot checks and rate limits.** YouTube may ask to "confirm you're not a bot", or rate-limit
502
+ subtitle downloads. Those errors aren't retried; cookies from a logged-in session help.
503
+ - **Sites change and yt-dlp follows quickly.** Keep the bundled version current with
504
+ `deps:update`.
505
+ - **App stores.** Google Play and the App Store reject apps that download from YouTube and
506
+ similar sites without the rights holder's authorization.
507
+
508
+ ## Licenses
509
+
510
+ MIT for this package. Bundled components:
511
+ - yt-dlp: Unlicense
512
+ - CPython: PSF-2.0
513
+ - OpenSSL: Apache-2.0
514
+ - libffi: MIT; xz (liblzma): public domain; bzip2: BSD-style (all linked into CPython)
515
+ - QuickJS-ng: MIT
516
+ - certifi: MPL-2.0
517
+ - yt-dlp-ejs: Unlicense, MIT and ISC
518
+ - ffmpeg, if bundled: LGPL-2.1+
519
+
520
+ The license texts ship in the APK (`assets/expo-yt-dlp/licenses`, and inside
521
+ `site-packages.zip`). List them in your app's third-party notices.
@@ -0,0 +1,18 @@
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
+ defaultConfig {
12
+ versionCode 1
13
+ versionName "0.1.0"
14
+ }
15
+ lintOptions {
16
+ abortOnError false
17
+ }
18
+ }
@@ -0,0 +1,3 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.INTERNET" />
3
+ </manifest>
@@ -0,0 +1,42 @@
1
+
2
+ --------------------------------------------------------------------------
3
+
4
+ This program, "bzip2", the associated library "libbzip2", and all
5
+ documentation, are copyright (C) 1996-2019 Julian R Seward. All
6
+ rights reserved.
7
+
8
+ Redistribution and use in source and binary forms, with or without
9
+ modification, are permitted provided that the following conditions
10
+ are met:
11
+
12
+ 1. Redistributions of source code must retain the above copyright
13
+ notice, this list of conditions and the following disclaimer.
14
+
15
+ 2. The origin of this software must not be misrepresented; you must
16
+ not claim that you wrote the original software. If you use this
17
+ software in a product, an acknowledgment in the product
18
+ documentation would be appreciated but is not required.
19
+
20
+ 3. Altered source versions must be plainly marked as such, and must
21
+ not be misrepresented as being the original software.
22
+
23
+ 4. The name of the author may not be used to endorse or promote
24
+ products derived from this software without specific prior written
25
+ permission.
26
+
27
+ THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS
28
+ OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
29
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
30
+ ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
31
+ DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
32
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
33
+ GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
34
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
35
+ WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
36
+ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
37
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
38
+
39
+ Julian Seward, jseward@acm.org
40
+ bzip2/libbzip2 version 1.0.8 of 13 July 2019
41
+
42
+ --------------------------------------------------------------------------