Paste a link, get an MP4 at the resolution you pick or an MP3 at the bitrate you pick. Nothing is ever stored on the server.
Important
Use this only for content you have the right to download — material you own, that is in the public domain, or that carries a licence permitting reuse (Creative Commons, for example). Downloading copyrighted content without the rights holder's permission may breach YouTube's Terms of Service and copyright law in your jurisdiction.
This tool does not circumvent DRM, age verification, or regional restrictions, and no such feature will be added. See SECURITY.md.
- What it does
- Quick start
- Desktop apps
- Requirements
- Configuration
- How it works
- API reference
- Performance
- Deployment
- Known limitations
- Troubleshooting
- Development
- Design decisions
- MP4 downloads at any resolution the video actually offers, each labelled with a size estimate. Prefers H.264 + AAC so the file plays everywhere, falling back to AV1/VP9 only above 1080p where YouTube publishes no H.264 rendition.
- MP3 extraction at 128 / 192 / 320 kbps, with the title, date and genre copied into ID3 tags.
- Playlists — pick entries from a checklist and get one ZIP back. A video that fails is
skipped and listed in
SKIPPED.txtrather than sinking the whole batch. - Honest progress. Two phases, labelled separately: the server fetching and converting, then the finished file transferring to your browser.
- Real error messages. Private, removed, age-restricted, region-blocked, members-only, live and DRM-protected videos each produce a specific, actionable message.
- Nothing retained. Every request downloads into its own scratch directory, which is deleted the moment the response stream closes — on success, on failure, and on disconnect.
- Dark mode, download history in
localStorage, and per-IP rate limiting.
A .dmg for macOS or an .exe installer for Windows, each bundling the server, a Python
runtime, yt-dlp and ffmpeg — nothing to install, nothing to configure, and downloads come
over your own connection rather than a datacenter IP.
cd desktop && npm install && npm run dist
# -> a .dmg on macOS, a .exe installer on WindowsNeither build is signed, so each system warns once on first launch. See docs/DESKTOP.md.
Everything — Node, Python, yt-dlp, ffmpeg — is in the image.
git clone https://github.com/thomashendrixkw-code/yttools.git
cd yttools
docker compose up --buildOpen http://localhost:3000.
The repo ships a devcontainer that installs yt-dlp and ffmpeg for you, so
"Code → Codespaces → Create codespace" is enough to get running.
Warning
YouTube blocks datacenter IPs aggressively, and that is what Codespaces uses. Expect
BLOCKED_BY_YOUTUBE on many videos even though the app is working — see
Troubleshooting. Codespaces is fine for working on the UI and the
API surface; do real downloads locally.
brew install yt-dlp ffmpeg # see below for other platforms
npm install
npm run devOpen http://localhost:3000. Visit /api/health to confirm both binaries were found — the
UI also shows a banner if either is missing.
| Tool | Why |
|---|---|
| Node.js ≥ 20.9 | Runs the Next.js server |
yt-dlp |
Fetches metadata and media |
ffmpeg |
MP3 encoding, and merging video + audio for anything above 720p |
Without ffmpeg, video downloads still work but fall back to progressive formats, which
top out at 720p; MP3 extraction is unavailable. The app detects this and adjusts rather
than producing a silent video.
Installing the binaries on other platforms
# Debian / Ubuntu
sudo apt-get install -y ffmpeg
sudo curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux \
-o /usr/local/bin/yt-dlp && sudo chmod a+rx /usr/local/bin/yt-dlp
# Arch
sudo pacman -S yt-dlp ffmpeg
# Windows (winget)
winget install yt-dlp.yt-dlp
winget install Gyan.FFmpeg
# Anywhere with Python
pipx install yt-dlpBoth are resolved from PATH, or from YT_DLP_PATH / FFMPEG_PATH if you set them.
Copy .env.example to .env.local. Every variable is optional.
| Variable | Default | Purpose |
|---|---|---|
YT_DLP_PATH |
(PATH lookup) | Absolute path to the yt-dlp binary |
FFMPEG_PATH |
(PATH lookup) | Absolute path to the ffmpeg binary |
RATE_LIMIT_MAX |
10 |
Requests allowed per IP per window |
RATE_LIMIT_WINDOW_MS |
60000 |
Rate-limit window length in ms |
DOWNLOAD_TIMEOUT_MS |
900000 |
Ceiling on a single yt-dlp invocation (15 min) |
MAX_PLAYLIST_ITEMS |
25 |
Cap on playlist entries per batch |
YT_DLP_COOKIES |
(unset) | Path to a cookies.txt for content your account can access |
Note
YT_DLP_COOKIES exists so you can download content your own account already has
legitimate access to — not to bypass an age gate or a region lock.
sequenceDiagram
autonumber
participant B as Browser
participant API as Next.js route handlers
participant Y as yt-dlp + ffmpeg
participant T as Temp directory
B->>API: POST /api/info { url }
API->>Y: yt-dlp -J --no-playlist <url>
Y-->>API: metadata JSON
API-->>B: title, thumbnail, duration, resolutions
B->>API: GET /api/progress?jobId=… (SSE, stays open)
B->>API: POST /api/download { url, type, quality, jobId }
API->>T: mkdtemp()
API->>Y: spawn with format selector
loop while downloading
Y-->>API: progress lines on stdout
API-->>B: SSE { stage, percent, speed, eta }
end
Y->>T: writes the finished file
API-->>B: 200 + Content-Disposition + the file stream
Note over API,T: scratch directory deleted when<br/>the stream closes, whatever happened
The two-phase progress is deliberate. The server-side phase (yt-dlp fetching and ffmpeg converting) and the browser-side phase (that file coming down the wire) measure genuinely different things, and no single percentage honestly combines them — so the UI labels them "Step 1 of 2" and "Step 2 of 2" instead of inventing a number.
/api/progress is purely cosmetic. If it never connects — a proxy that buffers SSE, a
second replica, an ad blocker — the download itself is unaffected and the bar simply stays
indeterminate.
Every endpoint is a Next.js route handler running on the Node runtime.
// Response — a single video
{
"kind": "video",
"video": {
"id": "aqz-KE-bpKQ",
"title": "Big Buck Bunny 60fps 4K - Official Blender Foundation Short Film",
"channel": "Blender",
"duration": 635,
"thumbnail": "https://i.ytimg.com/…",
"viewCount": 23352374,
"uploadDate": "20141110",
"webpageUrl": "https://www.youtube.com/watch?v=aqz-KE-bpKQ",
"isLive": false,
"qualities": [
{ "height": 2160, "label": "2160p60", "approxBytes": 1366140502, "fps": 60 },
{ "height": 1080, "label": "1080p60", "approxBytes": 261490674, "fps": 60 },
],
},
}A playlist URL returns { "kind": "playlist", "playlist": { … } } with a flat list of
entries. A watch?v=…&list=… URL is treated as a single video — that is what the user
clicked on.
{
"url": "https://www.youtube.com/watch?v=…",
"type": "video", // "video" | "audio"
"quality": 1080, // height in px for video, kbps for audio
"jobId": "abc123", // optional — correlates with GET /api/progress
"batchUrls": [], // optional — download many, return a ZIP
"batchName": "My playlist", // optional — names the ZIP
}preferSmaller trades codec compatibility for roughly half the bytes — see
Performance.
Responds with the media stream, Content-Type, Content-Disposition (RFC 5987 encoded so
non-ASCII titles survive), and — for single files — Content-Length.
The same work, reachable by navigation:
/api/download?url=…&type=video&quality=1080&jobId=…&smaller=1
This is what the web UI uses for single files, so the browser streams the response straight to disk with its own progress and resume support rather than JavaScript assembling the whole file in memory. Batches stay on the POST form.
Server-Sent Events carrying { stage, percent, speed, eta, detail }, where stage is one
of queued, downloading, merging, converting, packaging, done, error.
{
"ytDlp": { "available": true, "version": "2026.08.19", "path": "/opt/homebrew/bin/yt-dlp" },
"ffmpeg": { "available": true, "version": "9.0.1", "path": "/opt/homebrew/bin/ffmpeg" }
}Every failure returns the same envelope with a matching HTTP status:
{
"error": {
"code": "AGE_RESTRICTED",
"message": "This video is age-restricted.",
"hint": "This tool does not bypass age gates. Download it from an account that already has access, using your own exported cookies."
}
}| Code | Status | When |
|---|---|---|
INVALID_URL, INVALID_REQUEST |
400 | Bad input |
NOT_FOUND |
404 | Removed or nonexistent video |
PRIVATE_VIDEO, AGE_RESTRICTED, MEMBERS_ONLY |
403 | You do not have access |
DRM_PROTECTED |
403 | Cannot be downloaded, by design |
GEO_BLOCKED |
451 | Not available in the server's region |
LIVE_STREAM |
400 | Currently live or upcoming |
NO_MATCHING_FORMAT |
422 | That quality does not exist |
PLAYLIST_TOO_LARGE |
400 | Batch exceeds MAX_PLAYLIST_ITEMS |
RATE_LIMITED |
429 | Too many requests from your IP |
BLOCKED_BY_YOUTUBE |
429 | YouTube refused this server's IP |
MISSING_BINARY |
503 | yt-dlp or ffmpeg not installed |
TIMEOUT |
504 | Exceeded DOWNLOAD_TIMEOUT_MS |
CONVERSION_FAILED, UNKNOWN |
500/502 | Something else went wrong |
Measured on a 10-minute 1080p60 video, timed from clicking Download to the file landing on disk, driven through a real browser:
| Option | Bytes | Time |
|---|---|---|
| Default (H.264) | 256 MB | ~31 s |
| Smaller file (AV1/VP9) | 122 MB | ~19 s |
YouTube publishes an AV1 or VP9 rendition alongside H.264 at the same resolution, and it is routinely half the size. The "Smaller file" checkbox selects it. H.264 remains the default because it plays on anything; AV1 needs a reasonably recent device.
Recorded so nobody repeats them:
--concurrent-fragments— inert. YouTube serves these formats as one ranged file, so yt-dlp never fragments them and the flag has nothing to parallelise (27.3s vs 28.2s).- aria2c with 16 connections — measurably slower than yt-dlp's native downloader (9.8 vs 13.7 MiB/s over interleaved runs). Not worth the dependency.
- Tuning
--http-chunk-sizefrom 1M to 10M — no effect beyond noise. - Piping yt-dlp straight to the HTTP response — merging to stdout abandons MP4 for MPEG-TS and re-encodes at 0.37x realtime, then fails. Far worse than a temp file.
Beware that raw throughput measurements against YouTube drift a lot: the same command measured 9.0, 3.3 and 13.7 MiB/s within one hour as YouTube adjusted throttling. Compare options interleaved in a single run, never across sessions.
- Caching the metadata extraction.
/api/infoalready extracted the video; passing that document back via--load-info-jsonlets the download skip doing it again — 4.61s to 0.97s on an identical download, and unlike anything network-related, deterministic. - Letting the browser own the transfer instead of buffering the response into a
Blob. - The smaller-codec option, above.
Serverless platforms cannot host this — Cloudflare Pages/Workers, Vercel functions and
Netlify functions alike. The app spawns yt-dlp and ffmpeg and writes large temp files;
Workers has neither a process model nor a filesystem, and no adapter works around that. Use
the Docker image on Fly.io, Render, Railway, or any VPS.
Ready-made configs are included: fly.toml, render.yaml, railway.json.
See docs/DEPLOYMENT.md for step-by-step instructions, sizing, and
putting Cloudflare in front — including the 125-second origin timeout that makes 4K
downloads fail behind a proxied record, and how to authenticate the app with Cloudflare
Access.
docker compose up --build -d
# or
docker build -t yttools . && docker run -p 3000:3000 --tmpfs /tmp:size=8g yttoolsThe image installs Python, the standalone yt-dlp binary and ffmpeg alongside Node, runs
as a non-root user, and exposes a healthcheck on /api/health. CI builds it on every PR and
asserts that both binaries are present inside the running container.
Three things to plan for:
- Put it behind auth. Anyone who can reach the app can make your server download large files and hammer YouTube from your IP. The rate limiter is a courtesy, not a defence.
- Disk. Each in-flight download needs room for its file.
docker-compose.ymlmounts/tmpas an 8 GB tmpfs so nothing touches disk — size it for your traffic. - Replicas. Rate-limit counters and progress state live in process memory. With more than one replica you want sticky sessions, or the progress bar falls back to indeterminate and each replica enforces its own limit independently.
Warning
Pin and periodically bump YT_DLP_VERSION in the Dockerfile. yt-dlp needs regular updates
to keep working against YouTube; a months-old build will start failing.
- Playlist ZIPs are still buffered in the browser. Single files stream straight to disk,
but a batch is fetched into a
Blobfirst, because a list of URLs does not fit sensibly in a query string. Batches are usually MP3s and far smaller, but a large one will use memory proportional to the archive. - Progress and rate limiting are per-process, so they do not survive a restart or span replicas.
- Playlist batches are sequential, capped at
MAX_PLAYLIST_ITEMS, and the whole batch must finish before the ZIP starts streaming. - No authentication is built in. That is deliberate — put it behind whatever your infrastructure already uses.
- yt-dlp is a moving target. When YouTube changes something, downloads break until yt-dlp ships a fix and you rebuild.
"yt-dlp is not installed on the server"
The binary is not on PATH. Install it, or point YT_DLP_PATH at it. Check what the server
sees with curl localhost:3000/api/health.
"YouTube is blocking requests from this server" (HTTP 429)
YouTube challenges or refuses traffic it judges to be automated, and it is far more aggressive about this from datacenter IP addresses — GitHub Codespaces, CI runners, and most cloud VMs. The app is working correctly; YouTube is simply refusing it.
Options, best first:
- Run it from a residential connection. Local machine, home server, or a VPS on an IP that has not been flagged.
- Supply cookies. Export
cookies.txtfrom a browser where you are signed in and setYT_DLP_COOKIESto its path. This proves you are a person, not a bot — it does not grant access to anything your account did not already have. - Wait. Short-lived challenges sometimes clear on their own.
The exact yt-dlp message is written to the server log, which is worth reading — it distinguishes a block from a genuinely unavailable video.
A request fails with 502 and "yt-dlp couldn't process that video"
That is the catch-all for a yt-dlp failure with wording the app does not recognise. The
full stderr is logged server-side (look for [yt-dlp] exited with code … in the
terminal running the app); it is deliberately not returned to the browser, since it can
contain filesystem paths.
If the logged message looks like something the app should classify properly, please open an
issue with it — tests/errors.test.ts pins these strings.
Resolutions above 720p are missing
ffmpeg is not installed. YouTube serves anything above 720p as separate video and audio
tracks that have to be merged, and only ffmpeg can do that. Without it the app deliberately
restricts itself to progressive formats rather than handing you a silent video.
The progress bar never shows a percentage
The SSE connection is not getting through — usually a reverse proxy buffering the response.
The app already sends X-Accel-Buffering: no; for nginx also set proxy_buffering off; on
the location. Downloads work regardless; only the bar is affected.
Downloads are slow
Pick a lower resolution. The size estimate next to each option is a good predictor. 4K files routinely run past a gigabyte, and the server has to fetch the whole thing before it can start streaming it to you.
npm install
npm run dev # http://localhost:3000
npm run check # typecheck + lint + tests
npm run build| Script | Does |
|---|---|
npm run dev |
Dev server with hot reload |
npm run build |
Production build |
npm start |
Serve the production build |
npm run typecheck |
tsc --noEmit |
npm run lint |
ESLint (Next.js flat config) |
npm run format |
Prettier, write mode |
npm test |
Vitest, single run |
npm run test:coverage |
Vitest with V8 coverage |
npm run check |
typecheck + lint + test |
src/
app/
api/info/route.ts yt-dlp -J metadata for a video or playlist
api/download/route.ts Spawns yt-dlp, streams the file, deletes the scratch dir
api/progress/route.ts SSE progress channel
api/health/route.ts Binary availability probe
layout.tsx page.tsx Shell, pre-paint theme bootstrap, the single page
components/ Form, media card, tabs, playlist picker, toasts, footer
hooks/
useDownload.ts fetch + SSE + save-to-disk, with cancellation
useHistory.ts localStorage as an external store
lib/
ytdlp.ts All yt-dlp invocation and output parsing
validate.ts URL allowlist and request validation — the security boundary
errors.ts yt-dlp stderr → structured API errors
binaries.ts Locating yt-dlp / ffmpeg
temp.ts Scratch directories and orphan sweeping
progress.ts In-process pub/sub behind the SSE endpoint
rate-limit.ts format.ts types.ts
tests/ Vitest: validation, error mapping, formatting
tests/ covers pure logic — 123 tests over URL validation, yt-dlp error mapping and
formatting. These are the places where a regression is silent and dangerous:
parseYouTubeUrl sits between a request body and a spawned subprocess, and the error
patterns are the only thing between a user and a generic failure message.
Code that shells out to yt-dlp is not unit-tested; it would depend on YouTube being reachable and a specific video still existing. Verify those by hand against a Creative Commons or public-domain video — Big Buck Bunny (CC-BY, Blender Foundation) is a good one.
See CONTRIBUTING.md for conventions.
Why yt-dlp instead of a JavaScript library?
YouTube changes its site regularly, and each change breaks extraction. yt-dlp has a large
community and ships fixes within days; pure-JS ports like ytdl-core routinely sit broken
for weeks. Shelling out to a Python binary is less elegant than an npm install, and far more
likely to still work next month.
Why no database?
There is nothing worth persisting. Metadata comes from YouTube on demand, files are
transient by design, and download history is a per-browser convenience that belongs in
localStorage. Adding a database would mostly add a thing that can be breached.
Why download to a temp file instead of piping yt-dlp straight to the response?
Merging separate video and audio tracks needs a seekable output, so anything above 720p
cannot stream directly. Writing to a scratch directory also means the response carries a
real Content-Length — which is what gives the browser an accurate transfer bar — and that
a mid-download failure returns a clean JSON error instead of a truncated file.
Why is the file found by listing the directory?
yt-dlp sanitises output filenames in ways that are tedious to predict. Giving each request
its own mkdtemp directory turns "what did it call the file?" into "what is in this
directory?", which is exact.
Bug reports and pull requests are welcome — see CONTRIBUTING.md and the Code of Conduct. For security issues, please follow SECURITY.md rather than opening a public issue.
MIT © Thomas Hendrickx.
The licence covers the source code. It grants no rights over the content this software is used to download.