YT-Downloader
Every “free YouTube to MP3” site is a minefield of ads, tons of loading, fake download buttons, and quality caps. Also, they never seem to last, and it’s hard to come by video download support. In reality, the actual work — yt-dlp plus ffmpeg — is free and open source. YT-Downloader is the thin web app I wrapped around it so I never have to visit those sites again: paste a link, pick a format, click download. It runs on my own machine, nothing leaves the network, and there is no upsell.
It started as a YouTube MP3 grabber. Because yt-dlp is the engine underneath, it quietly accepts links from roughly 1800 sites and handles video formats too — the name just reflects the main use case.
1. The shape of the thing
The whole app is a FastAPI backend and a single static HTML page. No database, no build step on the frontend, no framework on the client. The interesting design decision was where to draw the line around yt-dlp.
yt-dlp is a sprawling library with a configuration surface to match. Left unchecked, its option dictionaries leak into every layer of an app. So I gave it exactly one neighbour. downloader.py opens with a one-line docstring that is also a rule:
"""The only module that touches yt-dlp: inspect URLs, download files, build ZIPs."""Everything yt-dlp-shaped lives behind that module — the YoutubeDL calls, the error translation, the file-path resolution. The FastAPI routes never import it. They speak in plain request and response models.
The option-building logic is split out one step further, into ytdl_options.py, which translates UI choices (“audio”, “mp3”, “320”) into a yt-dlp options dict. That module is deliberately pure — no I/O, not even a yt-dlp import:
"""Translate UI download options into a yt-dlp ``ydl_opts`` dictionary.
Pure module: no I/O, no yt-dlp import. Easy to unit-test.
"""That purity is the payoff. The trickiest logic in the app — lossless formats ignore bitrate, video mode caps resolution with a bestvideo[height<=N]+bestaudio format string, thumbnail and metadata embedding append ffmpeg postprocessors — is all in a module a test can call with a plain dataclass and assert on a plain dict. No network, no subprocess, no fixtures.
2. Returning a file without a database
A download request is synchronous: the browser asks for a file, the server fetches and transcodes it, the browser gets it back. The catch is that the finished file lives on the server’s disk for a moment, and the response that triggered the work isn’t the response that should carry the bytes — transcoding a long video can take a while, and I wanted the result delivered as a normal browser download, not an inline blob.
So /api/download does the work, writes the file into a temp directory unique to that job, and returns a token:
token = uuid.uuid4().hex
with _tokens_lock:
_tokens[token] = {
"path": path,
"dir": str(job_dir),
"filename": os.path.basename(path),
"created_at": time.time(),
}
return {"token": token}The browser then hits /api/file/{token}, which streams the file and — via a Starlette BackgroundTask — deletes the temp directory the moment the stream finishes. The token is popped on first use, so it works exactly once.
That leaves one gap: a token that’s never fetched. The user closes the tab, the file sits on disk forever. A background sweeper thread, started in the FastAPI lifespan handler, runs every five minutes and drops any token older than its TTL along with its directory. On startup the lifespan handler also wipes the work directory wholesale, so a crashed previous run can’t leak disk either.
The token store is a plain dict behind a threading.Lock. I considered Redis for about ten seconds and rejected it — this is a single-process app on one machine, and a dict with a lock is the honest size of the problem. Reaching for an external store here would be architecture cosplay.
3. Streaming a playlist as a ZIP
Single videos are easy. Playlists are where it gets interesting, because a playlist can be fifty videos and a few gigabytes, and I did not want to assemble that archive in memory or on disk before sending the first byte.
build_playlist_zip downloads each track into its own subdirectory, then hands the finished files to zipstream-ng, which builds the ZIP on the fly as the response streams:
stream = ZipStream(sized=False)
for arcname, path in files:
stream.add_path(path, arcname)The response is a StreamingResponse wrapping that iterator, again with a BackgroundTask cleanup. The container’s memory footprint stays flat whether the playlist is three videos or three hundred.
The other half of playlist handling is refusing to fail loudly on someone else’s broken data. Playlists are full of videos that have gone private, been removed, or are region-locked. One dead video should not sink the other forty-nine. So a failed track is caught, logged, and skipped — and rather than silently dropping it, the archive gets an _errors.txt manifest listing exactly what was skipped and why:
for idx, entry in enumerate(entries, 1):
try:
path = download_one(entry["url"], options, track_dir)
except DownloadFailed as exc:
errors.append(f"{entry.get('title') or f'track-{idx}'}: {exc}")
continue
files.append((_dedupe(os.path.basename(path), used), path))Filenames get deduplicated too — two tracks named the same thing become Title.mp3 and Title (2).mp3 instead of one clobbering the other. And because files leave the server with names like Channel - Title.mp3, the Content-Disposition header is built with both an ASCII fallback and an RFC 5987 filename* form, so a track with non-Latin characters in its title arrives named correctly instead of mojibake.
4. When YouTube fights back
YouTube increasingly answers download tools with a “Sign in to confirm you’re not a bot” wall — playlists trip it especially often. yt-dlp surfaces this as a long, link-laden error string, which is useless to show a user.
The fix is the cookies workaround: export your browser’s youtube.com cookies to a cookies.txt file, drop it in a folder, and yt-dlp authenticates as you. But that only helps if the user knows to do it. So the app detects the situation and walks them through it.
A small predicate classifies the error:
def _signin_wall(message: str) -> bool:
low = message.lower()
return ("sign in to confirm" in low or "not a bot" in low
or "confirm your age" in low)When that matches, the raw error is collapsed into a one-sentence hint pointing at cookie setup. And the failure carries a cookies_expired flag — set only when the sign-in wall was hit and a cookies file was already in use, which is the specific signature of cookies that have gone stale. The frontend turns that flag into a banner telling the user to refresh their cookies.txt, rather than leaving them to guess why downloads that worked last month stopped.
That distinction — “no cookies yet” versus “cookies present but expired” — is a small thing that took a deliberate second pass to get right. An early version computed the flag by calling cookie_file() twice, which is a time-of-check-to-time-of-use race; the fix was to resolve the cookie path once and reuse the result. Small bug, but the kind that only exists because the feature is precise about what it’s claiming.
5. Packaging — and the one dependency I refuse to pin
The app ships as a Docker image: Python, ffmpeg, the dependencies, the code. docker compose up -d --build and it’s running. That matters because the audience for this is partly non-technical — the README is a deliberately exhaustive, no-Docker-experience-assumed setup guide — and “install one program, run one command” is the only setup most people will tolerate.
One dependency is intentionally left unpinned: yt-dlp itself.
# yt-dlp is intentionally unpinned: YouTube changes break old versions.
yt-dlpThis breaks a normal rule. Reproducible builds want every version pinned. But YouTube changes its internals constantly, and a pinned yt-dlp is a download tool with an expiry date. Leaving it unpinned means docker compose up -d --build doubles as the update mechanism — rebuilding pulls the latest release and the newest round of YouTube fixes. The reproducibility I’d lose is reproducibility of a thing that’s already broken. It’s a considered exception, not an oversight, which is exactly why it’s a comment in the requirements file.
The test suite splits along the same fault line: pytest -m "not network" runs the fast offline tests — the pure options module, the API routes with yt-dlp mocked — and the unmarked tests hit the network for real integration coverage. Fast feedback by default, real coverage on demand.
6. What it is, and what it isn’t
YT-Downloader is a pet project, and it’s scoped like one. There’s no auth, no multi-user story, no job queue — it’s a tool for one person on their own machine, and pretending otherwise would have meant building three subsystems nobody asked for.
What I’m happy with is that the small surface is built honestly. The yt-dlp blast radius is one module. The hardest logic is pure and trivially testable. The file lifecycle has no leaks — temp dirs are cleaned on stream completion, on TTL expiry, and on startup. Playlists stream instead of buffering, and they degrade gracefully on the messy real-world data they’re guaranteed to hit. None of that is exotic. It’s just the difference between a script and something I’d hand to someone else and not worry about.
Most importantly, it’s a tool I actually use myself, and using a tool I created on my own is a good feeling.