Kyro Downloader: one engine, four UIs, and a lot of learning about contracts
Kyro Downloader is a Python media downloader wrapped around yt-dlp. The thing that makes it interesting (to me anyway) is that it has four entry points — CLI, TUI, desktop GUI, and a web UI — and they all share the same core download engine.
Why did I do this to myself? Because I kept reading tutorials where the CLI version and the GUI version of the same tool drifted apart over time, and I wanted to know if it was avoidable.
It is avoidable. It's just a lot of work.
The shape
downloader/
core/ ← the engine. knows about yt-dlp, presets, queues.
cli/ ← Click-style commands.
tui/ ← Textual app.
gui/ ← CustomTkinter.
web/ ← FastAPI + WebSockets.
Every UI calls the same core functions. None of them know about yt-dlp directly. If I want to swap engines tomorrow, I rewrite core/ and the four UIs come along for free.
The lesson I learned the hard way
The first version did not look like that. The first version had the CLI call yt-dlp directly and the GUI call it too, and the web UI had its own version of "is this a playlist." When I tried to add a feature (subtitle downloads), I had to change four things. I forgot one. Things broke. I learned.
I rewrote it with a core/ module and a single Downloader class, and every UI is now a thin wrapper that:
- Parses input (URL, flags, form fields, button clicks).
- Calls
core.download(...)with a typed config object. - Subscribes to progress events.
- Renders progress its own way.
That's it. Four UIs, one engine.
Presets: the feature I'm most happy with
There are presets like "voice memo" (low-bitrate mono MP3), "podcast" (medium-bitrate stereo), and "lossless" (FLAC). Each preset is a single dataclass in core/presets.py. The CLI exposes them as flags. The web UI exposes them as a dropdown. The GUI exposes them as buttons. Same presets everywhere — change the dataclass, every UI updates.
This is the kind of thing AI is really good for. I described what I wanted ("presets that work the same way in every interface") to Claude and it suggested the dataclass + registry pattern. Once I saw it, it was obvious. But I needed to see it.
The WebSocket part
The web UI needs progress updates in real time. FastAPI + WebSockets does this nicely, but I'd never done WebSocket plumbing before. The pattern that worked:
- Core engine emits progress events to an in-memory pub/sub.
- WebSocket handler subscribes and forwards to the connected client.
- Client renders.
Nothing fancy. The mistake I made the first time was tying the WebSocket directly to the download — so if the client disconnected, the download stopped. Splitting them with a pub/sub means the download keeps going and reconnecting clients catch up.
What AI helped with
- Naming the engine boundary. I went back and forth on whether it was a "Downloader," "Engine," "Service," etc. AI didn't pick the name for me but it helped me articulate why each name implied different things.
- Textual quirks. Textual is a great TUI framework but the docs are still evolving. AI was useful for "I want a progress bar inside a scrollable list, here's my markup, why won't it render."
- Async/await sanity checks. Python async is a footgun. I'd write something, Claude would say "you're awaiting a sync function, this does nothing," and I'd be embarrassed for ten seconds and then fix it.
What I'd do differently
The packaging story is rough. I have a pyproject.toml but I don't have proper platform builds for the GUI (PyInstaller stuff). That's the next big chunk of work. A user on Windows shouldn't have to pip install and pray.
The honest "why bother"
Could I have just used yt-dlp directly from the terminal? Sure. Most days I do. But building this taught me about engine boundaries in a way no tutorial did. The next time I write a tool that needs multiple interfaces, I won't have to think hard about how to share the core — I already know the shape.