Xownloader
Self-hosted YouTube and Instagram media downloader: a FastAPI backend behind a provider-adapter boundary, and one Flutter client for every platform.
Problem
Downloading my own media had always meant a pile of personal yt-dlp
scripts: one-offs, no persistence, no queue, no way to check on a
download from my phone. I wanted an actual system: submit a link from
any device, watch it queue and progress, get the file back, without
babysitting a terminal. And YouTube alone wasn’t the target. Instagram
posts, reels, stories, and highlights needed to work the same way,
which turned out to be a much harder problem than YouTube ever was.
Approach
I’d only ever built desktop clients in Qt/C++ before this, so the stack
was a deliberate first: FastAPI for the backend, Flutter for the
client. The server owns everything provider-specific behind a
ProviderRegistry: a URL comes in, gets routed to a yt-dlp adapter
or an Instagram adapter, and neither adapter knows the other exists.
Jobs persist in SQLite, survive a restart, and get cleaned up on a
retention timer. The Flutter client is one codebase across Android,
iOS, Linux, macOS, Windows, and web, talking to that API and nothing
else. No provider logic ever leaks into the client.
FastAPI’s ceremony-free way of standing up a real backend (routes, validation, dependency injection, all in plain Python with almost no boilerplate) was the most immediately obvious win coming from a C++/Qt background, closer to the experience of Express.js than anything in the Qt world. Flutter’s trade-off was less clear-cut: the declarative widget model and hot reload make iteration fast, but I missed Qt’s directness with native platform APIs more than once, especially wiring up Android share intents and an iOS Share Extension.
Instagram was the real problem. My first adapter talked to Instagram’s
web API directly with a browser session cookie, and it worked, until
it silently didn’t: some fully public accounts returned “stories not
accessible” for no visible reason, with no error worth debugging.
Switching to instagrapi (a library built around Instagram’s private
mobile API) fixed it, but brought its own edge: a session has to be
established through a specific load_settings → login →
dump_settings sequence to avoid retriggering a full login (and the
account lockout risk that comes with it), and an account with two-factor
authentication needs a live TOTP code generated from a stored secret,
not a static one.
Architecture
The client never talks to a provider directly: every request goes
through one API, which is the only thing that knows yt-dlp and
Instagram exist.
Outcome
The three production bugs that turned up after deploying it were the
most instructive part of the whole project. An existing nginx rule
injected a fixed API token into every request without one (meant so
the web build never has to embed a secret in its JavaScript), and it
was silently overriding the admin panel’s real token too, so admin
login failed with no clue why from either side. A docker-compose
v1 recreate against a BuildKit-built image threw a bare
KeyError: 'ContainerConfig', a known incompatibility with the
container’s newer image format, invisible until the exact command
that triggers it. And enabling a second UI language crashed the app
instantly on launch, because Flutter’s own Material widgets need
flutter_localizations registered to function at all in a non-English
locale. A widget-test suite that never runs outside English had no
way to catch it, so the fix shipped with a regression test that forces
the Turkish locale specifically to prove the crash is gone.
The result runs end to end: YouTube via yt-dlp and Instagram (posts,
reels, carousels, stories, highlights) via instagrapi, a bilingual
Flutter client with a token-gated read-only admin view, deployed
behind nginx on a personal domain.