Let me tell you how modern media downloading actually works in 2026. It is not HTTP GET video.mp4, thank you, goodbye. It is an arms race.

On one side: yt-dlp, 2000+ extractors, a volunteer army reverse-engineering JavaScript players before breakfast. On the other: YouTube, TikTok, Instagram, Cloudflare, Akamai, DataDome, rotating ciphers, obfuscated players, nsig throttling, PO tokens, and TLS fingerprinting that spots your Python script from three packets away and says: no. Bot. Begone.

If you run the stable yt-dlp from your distro repo on a phone, you lose twice. Once because it is old, once because it looks like a bot. So we packaged the fix in the Termux repo: yt-dlp-git plus curl-cffi.

Repo: github.com/Twilight0/termux-repo Builder: github.com/Twilight0/curl-cffi-builder

curl -sL https://twilight0.github.io/termux-repo/install.sh | bash
pkg update
pkg install -y yt-dlp-git curl-cffi

Part 1: yt-dlp-git — why stable is not enough

yt-dlp stable releases roughly monthly. Websites hosting media change roughly hourly. You do the math.

What breaks, constantly:

  • YouTube player JS: signature cipher (sig), nsig (throttling bypass), SABR streaming, PO token requirements. Google pushes a new base.js and suddenly your month-old stable downloads at 50 KB/s, or gets Sign in to confirm you're not a bot, or extracts no formats at all. Fix lands in git master within hours. Stable users wait weeks.
  • Extractors: TikTok changes API endpoints, Vimeo shuffles JSON, Instagram requires new dtoken, Twitch kills an endpoint, Bandcamp renames a field. Each fix is a 20-line extractor patch. Master gets dozens per week.
  • New features: --impersonate targets, new format sort options, SponsorBlock integration tweaks, subtitle fixes, chapter embedding. Stable is a snapshot. Master is the river.

Capabilities reminder, because people forget how absurdly powerful this tool is: best-video+best-audio auto-merge, format selection (-F, -f bv+ba/b), subtitles (--write-subs --sub-langs all), thumbnails, chapters, metadata, SponsorBlock (--sponsorblock-remove), playlists, archives (--download-archive so you never re-download), cookies from browser, proxy / IPv4-IPv6 forcing, cache dir for client IDs and signatures.

On Termux official repos, yt-dlp lags. You get a version from last month trying to parse this morning’s YouTube. Our yt-dlp-git tracks git master snapshots, rebuilt weekly via CI. When YouTube breaks on Tuesday night, you pkg upgrade on Wednesday, not next month.

pkg install -y yt-dlp-git
yt-dlp --version
# 2026.09.xx not 2026.06.xx — that two-month gap is the difference
# between working and whining

Part 2: curl-cffi — optional, until it isn’t

Here is the dirty secret: setting User-Agent: Mozilla/5.0 ... Chrome does almost nothing. Any half-serious WAF stopped checking User-Agent in

  1. What they check is how your TLS handshake looks on the wire.

Python’s requests, httpx, urllib3 have a distinct fingerprint:

  • TLS version, cipher suite list and order (4865-4866-4867...)
  • Extension IDs and order (0-11-10-35-16-5...), GREASE, ALPN
  • Supported groups / curves, EC point formats, signature algorithms
  • Certificate compression (brotli/zlib), ticket handling, key shares
  • HTTP/2 SETTINGS frame (1:65536;3:1000;4:6291456...), WINDOW_UPDATE, PRIORITY frames, pseudo-header order (:method :authority :scheme :path)

That concatenated blob is JA3 + Akamai HTTP/2 fingerprint. Chrome sends one pattern, Python sends another. Cloudflare sees Python JA3 asking for video segments and bins you: 403, challenge, infinite redirect, empty formats list. Your headers were perfect. Your handshake betrayed you.

curl-impersonate fixes this by patching curl + BoringSSL to byte-mimic real browsers. curl_cffi brings that to Python via CFFI:

Python binding for curl-impersonate via cffi. Unlike pure-python clients, it can impersonate browsers’ TLS signatures / JA3 fingerprints.

yt-dlp wires it in as an optional dependency:

yt-dlp --list-impersonate-targets

gives you chrome, chrome-110, chrome124, chrome131_android, edge99, safari15_3, safari17_0, firefox133, etc. Use latest Chrome / Safari unless you have a reason not to. yt-dlp docs even warn: forcing impersonation for all requests can hurt speed/stability, so use it when the site needs it.

Python-side, same idea:

from curl_cffi import requests
r = requests.get(url, impersonate="chrome")
print(r.status_code)

Or low-level, if you enjoy pain:

from curl_cffi import Curl, CurlOpt
c = Curl()
c.setopt(CurlOpt.HTTP2_PSEUDO_HEADERS_ORDER, "masp")

Upstream curl_cffi ships wheels for desktop Linux / macOS / Windows 64-bit. What it does not ship: Android, iOS, 32-bit ARM, Kodi boxes. Read the PyPI fine print: excluded from Unix zipimport binary, x86 32-bit, musllinux_aarch64. If you are on a phone, upstream pretends you don’t exist.

That is why curl-cffi in our repo matters, and why we built it even for armeabi-v7a when upstream didn’t.


Part 3: how we built curl-cffi for armeabi-v7a anyway

Upstream logic is sound, from a desktop perspective: 32-bit ARM is dying, Android wheels are hell, iOS wheels are double hell. Our logic is different: the phones in drawers that we want as workstations are 32-bit ARM. An old Galaxy Tab, a cheap Xiaomi, a TV stick — armeabi-v7a is still everywhere outside the flagship bubble. Zero-bloat means not abandoning working hardware because CI is annoying.

So curl-cffi-builder does the annoying part on GitHub Actions:

  1. NDK toolchain: Android NDK, armv7a-linux-androideabi21 target (plus aarch64-linux-android, x86_64-linux-android, iOS arm64). Matrix builds: ubuntu-latest for Android/Linux, macos-latest for iOS, windows-latest for Windows Kodi.
  2. Native stack from source: BoringSSL (Chrome’s fork, not OpenSSL), nghttp2, brotli, zstd, then curl-impersonate’s patched curl against that stack. No system curl. No shortcuts. If one lib assumes 64-bit long, the 32-bit build dies screaming — patch and retry.
  3. cffi binding fixups: upstream setup.py assumes desktop tags, 64-bit pointers, prebuilt .so names. We rewrite platform tags, fix long vs int64 struct layouts for ARMv7, force soft-float / NEON flags correctly, and produce importable wheels: curl-cffi-android-arm64-v8a.whl, curl-cffi-android-x86_64.whl, curl-cffi-ios-arm64.whl.
  4. Kodi bonus: same pipeline assembles script.module.curlcffi-*.zip — multi-arch Kodi module with dynamic path injection for Windows / Linux / Android TV / CoreELEC / macOS. Same fingerprint bypass your TV box enjoys.
  5. Termux APT repack: for the Termux repo we don’t ship pip wheels (pip would reject the platform tag). We ship native .debs per Termux arch, including armeabi-v7a, with proper DEBIAN/control, postinst that drops the .so where Python finds it. pkg install curl-cffi just works, even on the 32-bit tablet everyone told you to throw away.

Is it officially supported upstream? No. Does it work? Yes. That’s the whole AliveOS thesis: support the hardware people actually have.


Part 4: download using impersonate — practical recipes

Install once:

pkg update
pkg install -y yt-dlp-git curl-cffi ffmpeg python

Check what your build can fake:

yt-dlp --list-impersonate-targets

Basic: let yt-dlp pick whatever works:

yt-dlp --impersonate="" \
  -o "%(title)s [%(id)s].%(ext)s" \
  "https://www.youtube.com/watch?v=XXXX"

Explicit Chrome, the workhorse for Cloudflare-fronted sites:

yt-dlp --impersonate chrome \
  --merge-output-format mp4 \
  -f "bv+ba/b" \
  "URL"

Pinned version + OS when a site is picky (some WAFs validate the User-Agent matches the fingerprint generation):

yt-dlp --impersonate chrome-124:windows-10 \
  --cookies-from-browser chrome \
  -F "URL"   # list formats first, then download

Audio-only archival, phone-friendly:

yt-dlp --impersonate safari \
  -x --audio-format mp3 --audio-quality 0 \
  --write-subs --sub-langs "en.*" \
  --download-archive archive.txt \
  "PLAYLIST_URL"

Debug when it still fails (it will, sometimes):

yt-dlp -v --impersonate chrome --list-formats "URL"
# -v shows JA3 / TLS errors, 403 vs empty formats,
# nsig / PO token warnings — read it before blaming us

Rules of thumb from the trenches:

  • Start without --impersonate. Add it when you get 403 / challenge / no formats found on a site that works in real Chrome.
  • Don’t force impersonation globally if you don’t need it — upstream warns it can hurt speed/stability. Per-site, per-command.
  • Update weekly. pkg upgrade pulls new yt-dlp-git snapshots. Yesterday’s extractor is today’s 404.
  • On 32-bit ARM (armeabi-v7a), keep expectations honest: impersonation costs CPU (BoringSSL + HTTP/2). It works, but don’t parallelize -N 8 on a 2016 tablet and wonder why it thermal-throttles. That is physics, not a bug. See our Fermi reclocking notes if you want to know how seriously we take thermals.

Why this belongs in AliveOS-on-Android

Phones are hostile territory: Bionic, seccomp, SELinux, no systemd, battery as bloat detector. The answer isn’t to port the whole desktop. It’s the Dory lesson again — port the workflow, not the furniture. See retiring Dory and AliveOS on Android.

yt-dlp-git + curl-cffi is exactly that: two small packages that turn a consumption slab back into a tool. Archive lectures, mirror your own videos, grab audio for offline listening, bypass the fingerprint wall without a laptop.

Old hardware, new tricks. Zero bloat, full teeth.

pkg install -y yt-dlp-git curl-cffi
yt-dlp --impersonate chrome "URL"

Break it, file issues with device + Android version + uname -a + verbose log. We can’t test every phone. You are the lab.


See Also

  • Termux repowrangler, muse-code, opencode, yt-dlp-git, curl-cffi, install scripts, CI, DNS guides.
  • curl-cffi-builder — cross-compilation pipeline for Android / iOS wheels + Kodi script.module.curlcffi.
  • AliveOS on Android: Termux, PRoot — the bigger picture: proot Arch, glibc swamp, why phones deserve better.