ytdlp-react-native
v0.1.0
Published
React Native / Expo port of the ytdlp-typescript engine: YouTube, TikTok, Facebook, Pinterest, VK, Telegram and BiliBili extraction without Node.js builtins
Maintainers
Readme
ytdlp-react-native
React Native / Expo port of the ytdlp-typescript engine — a TypeScript rewrite of the yt-dlp extraction core with zero Node.js builtins.
Supported platforms:
- YouTube (innertube API client chain, sig + nsig challenge solving via a pure-JS interpreter, HLS/DASH manifests, subtitles, PO-token passthrough)
- TikTok (universal-data scrape, WAF challenge solver, short-link redirects)
- Facebook (ScheduledServerJS relay data, tahoe fallback)
- Pinterest (resource API, master/media HLS playlists)
- VK (al_video payload, JS salt challenge), Telegram (embed frames), BiliBili (wbi signing)
Zero runtime dependencies. Works on Hermes / JSC / V8-based React Native and Expo.
Why a separate package
The Node version relies on node:vm, node:fs, node:crypto and node:child_process. None of these exist in React Native. This package replaces them with an injectable runtime layer:
| Node builtin | ytdlp-react-native replacement |
| --- | --- |
| node:vm (player-JS sandbox) | Pure-JS interpreter (src/jsinterp) ported from upstream jsinterp.py concepts |
| node:fs | FileSystem interface + MemoryFileSystem; Expo adapter included |
| node:crypto | CryptoRuntime interface; pure-JS MD5/SHA-256/AES-128 included, react-native-quick-crypto adapter optional |
| node:child_process (ffmpeg) | Merger interface; merging is opt-in via your own implementation |
| Buffer | Uint8Array + base64 polyfill |
Install
npm install ytdlp-react-native
# or
bun add ytdlp-react-nativeOptional peer dependencies for native performance:
npx expo install expo-file-system # persistent downloads
npm install react-native-quick-crypto # fast MD5/SHA/AESQuick start (Expo)
import { extract, setRuntime } from 'ytdlp-react-native'
import { createExpoFileSystem } from 'ytdlp-react-native/dist/adapters/expo-file-system'
import { createQuickCrypto } from 'ytdlp-react-native/dist/adapters/quick-crypto'
setRuntime({
fs: createExpoFileSystem(),
crypto: createQuickCrypto(),
})
const info = await extract('https://www.tiktok.com/@user/video/123456')
console.log(info.title, info.formats.length)
info.formats.forEach((f) => console.log(f.format_id, f.ext, f.height, f.url))If you only need format URLs (playback via expo-av / react-native-video), no filesystem setup is required at all.
Example app (App.tsx)
A complete, minimal Expo app: paste a URL, extract formats, tap to download with a live progress bar.
// App.tsx
import React, { useState } from 'react'
import {
ActivityIndicator,
Linking,
Pressable,
ScrollView,
StatusBar,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native'
import {
download,
extract,
setRuntime,
type InfoDict,
type ProgressStatus,
} from 'ytdlp-react-native'
import { createExpoFileSystem } from 'ytdlp-react-native/dist/adapters/expo-file-system'
import { createQuickCrypto } from 'ytdlp-react-native/dist/adapters/quick-crypto'
// Configure once, before any extract/download call.
setRuntime({
fs: createExpoFileSystem(),
crypto: createQuickCrypto(),
})
function fmtDuration(s?: number | null): string {
if (!s) return '-'
const m = Math.floor(s / 60)
return `${m}:${String(Math.round(s % 60)).padStart(2, '0')}`
}
export default function App() {
const [url, setUrl] = useState('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
const [busy, setBusy] = useState(false)
const [info, setInfo] = useState<InfoDict | null>(null)
const [status, setStatus] = useState('')
const [percent, setPercent] = useState<number | null>(null)
async function handleExtract() {
setBusy(true)
setInfo(null)
setStatus('')
try {
setInfo(await extract(url.trim()))
} catch (e) {
setStatus(`Extract failed: ${(e as Error).message}`)
} finally {
setBusy(false)
}
}
async function handleDownload() {
if (!info) return
setBusy(true)
setPercent(0)
try {
const files = await download(info, {
format: 'b',
outtmpl: '%(title)s [%(id)s].%(ext)s',
onProgress: (p: ProgressStatus) => {
if (p.downloaded_bytes != null && p.total_bytes) {
setPercent(Math.round((p.downloaded_bytes / p.total_bytes) * 100))
}
},
})
setStatus(`Saved: ${files[0].filepath}`)
} catch (e) {
setStatus(`Download failed: ${(e as Error).message}`)
} finally {
setBusy(false)
setPercent(null)
}
}
return (
<View style={styles.root}>
<StatusBar barStyle="light-content" />
<Text style={styles.title}>ytdlp</Text>
<TextInput
style={styles.input}
value={url}
onChangeText={setUrl}
placeholder="Video URL"
placeholderTextColor="#666"
autoCapitalize="none"
/>
<Pressable
style={[styles.btn, busy && styles.btnDisabled]}
onPress={handleExtract}
disabled={busy}
>
{busy ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.btnLabel}>Extract</Text>
)}
</Pressable>
{info && (
<ScrollView style={styles.card} showsVerticalScrollIndicator={false}>
<Text style={styles.videoTitle} numberOfLines={2}>
{info.title}
</Text>
<Text style={styles.meta}>
{info.uploader ?? info.channel ?? ''} · {fmtDuration(info.duration)} ·{' '}
{info.formats.length} formats
</Text>
<Pressable
style={[styles.btn, styles.dlBtn, busy && styles.btnDisabled]}
onPress={handleDownload}
disabled={busy}
>
<Text style={styles.btnLabel}>
Download best{percent !== null ? ` — ${percent}%` : ''}
</Text>
</Pressable>
{status !== '' && <Text style={styles.status}>{status}</Text>}
<Text style={[styles.meta, styles.listHeader]}>Formats (tap URL opens):</Text>
{info.formats.slice(0, 10).map((f) => (
<Text
key={String(f.format_id)}
style={styles.formatRow}
onPress={() => f.url && Linking.openURL(f.url)}
>
{String(f.format_id).padEnd(8)}
{f.ext.padEnd(5)}
{f.height ? `${f.height}p` : 'audio'}
{f.tbr ? ` ${Math.round(f.tbr)}kbps` : ''}
</Text>
))}
</ScrollView>
)}
</View>
)
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#111', padding: 20, paddingTop: 70 },
title: { color: '#7dd3fc', fontSize: 28, fontWeight: '800', marginBottom: 16 },
input: {
backgroundColor: '#1e1e1e',
borderColor: '#333',
borderWidth: 1,
borderRadius: 8,
color: '#eee',
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 12,
},
btn: {
backgroundColor: '#2563eb',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
dlBtn: { marginTop: 14, backgroundColor: '#059669' },
btnDisabled: { opacity: 0.5 },
btnLabel: { color: '#fff', fontWeight: '600' },
card: { marginTop: 18 },
videoTitle: { color: '#fff', fontSize: 17, fontWeight: '600' },
meta: { color: '#999', fontSize: 13, marginTop: 6 },
listHeader: { marginTop: 16, marginBottom: 4 },
status: { color: '#fbbf24', fontSize: 13, marginTop: 10 },
formatRow: { color: '#93c5fd', fontFamily: 'monospace', fontSize: 12, paddingVertical: 3 },
})Notes:
format: 'b'picks the best already-muxed stream so no merge step is needed on-device.- The progress bar reflects bytes for HTTP downloads; HLS/DASH fragment downloads report
fragment_index/fragment_countinstead.
Downloading to a file
import { download, setRuntime } from 'ytdlp-react-native'
const files = await download('https://www.pinterest.com/pin/123456/', {
format: 'b',
outtmpl: '%(title)s [%(id)s].%(ext)s',
onProgress: (p) => console.log(p.status, p.downloaded_bytes, p.total_bytes),
})Notes on downloading on-device:
- The HTTP downloader uses ranged chunk requests (default 8 MiB) so memory stays bounded even where RN
fetchcannot stream response bodies. - Merged formats (
bv*+ba) are skipped unless you provide aMerger. Without one, pass a muxed-friendly spec such as'b'or'bv*[acodec!=none]'.
Runtime injection
import { setRuntime } from 'ytdlp-react-native'
import type { YtDlpRuntime } from 'ytdlp-react-native'
const runtime: YtDlpRuntime = {
fs: myFileSystem, // required for download()
crypto: myCrypto, // required for TikTok challenges, VK salt, bilibili wbi, AES HLS
js: myEvaluator, // optional custom JS evaluator (defaults to built-in interpreter)
merger: myMerger, // optional ffmpeg-kit wrapper for bv*+ba merges
}
setRuntime(runtime)The pure-JS fallbacks (MemoryFileSystem, PureJsCrypto, built-in interpreter) work everywhere but are slower: the TikTok WAF proof-of-work can take seconds to minutes without native SHA-256, and the interpreter is slower than node:vm for player challenges.
Options
| Option | Default | Description |
| --- | --- | --- |
| format | bv*+ba/b | yt-dlp-style selector (b, w, bv*, ba, filters like [height<=720], direct itags like 137+140) |
| outtmpl | %(title)s [%(id)s].%(ext)s | Output filename template |
| paths | – | { home } output directory (joined POSIX-style) |
| cookieJar | – | Programmatic CookieJar (auth / private content) |
| cookiesFromNetscape | – | Netscape cookies.txt as a string |
| httpHeaders / userAgent | – | Extra headers |
| retries / fragmentRetries | 10 | Network retry counts |
| rateLimit | – | Bytes/sec throttle |
| concurrentFragments | 1 | Parallel HLS/DASH fragment downloads |
| poToken | – | { gvs?, player?, subs? } YouTube proof-of-origin tokens |
| noMerge | false | Skip merged-format attempts |
| onProgress | – | Progress callback (started / downloading / finished) |
Documentation
Extractors mirror upstream yt-dlp naming so cross-referencing the Python source stays easy. See the sibling project's docs for deep dives: docs/API.md, docs/format-selection.md.
Known limitations
- Single videos only — playlists/channels not implemented
- Live streams not supported
- No ffmpeg merge out of the box (inject a
Merger, e.g. aroundffmpeg-kit-react-native) - The JS interpreter does not support regex literals, classes, generators or async/await (YouTube sig/nsig functions do not use them); if a future player change breaks nsig solving, pass a custom
jsevaluator backed by a WebView orreact-native-nitro-modules - No browser impersonation equivalent; bot walls are mitigated per-platform via UA strategies
Development
bun install
bun run typecheck # tsc --noEmit (strict; must stay clean)
bun run test # vitest, offline unit tests
bun run build # tsc -> dist/Legal
This project does not host or distribute any copyrighted media; it is a technical client for platforms' publicly served streams. You are responsible for complying with each platform's Terms of Service and applicable copyright law in your jurisdiction. Not affiliated with YouTube, TikTok, Facebook/Meta, Pinterest, VK, Telegram or BiliBili.
