• Switch to Warm
  • Switch to Cool
  • Switch to Ember
  • Switch to Dusk
  • Switch to Plum
  • Switch to Midnight
  • Switch to Harbor
  • Switch to Cobalt
  • Switch to Noir
  • Switch to Tropic
  • Workspace
  • Projects
  • Notes

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_settingslogindump_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.

Architecture diagram: a Flutter client on Android, iOS, Linux, macOS, Windows, and web sends requests through an Nginx reverse proxy, which also serves the static Flutter web build. Nginx forwards API traffic to a FastAPI backend, which reads and writes a SQLite database and exposes an admin scope for status, jobs, and metrics. The backend routes downloads through a provider registry to either a yt-dlp adapter for YouTube or an instagrapi adapter for Instagram, the latter backed by a secure session store holding the login session and TOTP secret.Architecture diagram: a Flutter client on Android, iOS, Linux, macOS, Windows, and web sends requests through an Nginx reverse proxy, which also serves the static Flutter web build. Nginx forwards API traffic to a FastAPI backend, which reads and writes a SQLite database and exposes an admin scope for status, jobs, and metrics. The backend routes downloads through a provider registry to either a yt-dlp adapter for YouTube or an instagrapi adapter for Instagram, the latter backed by a secure session store holding the login session and TOTP secret.Architecture diagram: a Flutter client on Android, iOS, Linux, macOS, Windows, and web sends requests through an Nginx reverse proxy, which also serves the static Flutter web build. Nginx forwards API traffic to a FastAPI backend, which reads and writes a SQLite database and exposes an admin scope for status, jobs, and metrics. The backend routes downloads through a provider registry to either a yt-dlp adapter for YouTube or an instagrapi adapter for Instagram, the latter backed by a secure session store holding the login session and TOTP secret.Architecture diagram: a Flutter client on Android, iOS, Linux, macOS, Windows, and web sends requests through an Nginx reverse proxy, which also serves the static Flutter web build. Nginx forwards API traffic to a FastAPI backend, which reads and writes a SQLite database and exposes an admin scope for status, jobs, and metrics. The backend routes downloads through a provider registry to either a yt-dlp adapter for YouTube or an instagrapi adapter for Instagram, the latter backed by a secure session store holding the login session and TOTP secret.Architecture diagram: a Flutter client on Android, iOS, Linux, macOS, Windows, and web sends requests through an Nginx reverse proxy, which also serves the static Flutter web build. Nginx forwards API traffic to a FastAPI backend, which reads and writes a SQLite database and exposes an admin scope for status, jobs, and metrics. The backend routes downloads through a provider registry to either a yt-dlp adapter for YouTube or an instagrapi adapter for Instagram, the latter backed by a secure session store holding the login session and TOTP secret.Architecture diagram: a Flutter client on Android, iOS, Linux, macOS, Windows, and web sends requests through an Nginx reverse proxy, which also serves the static Flutter web build. Nginx forwards API traffic to a FastAPI backend, which reads and writes a SQLite database and exposes an admin scope for status, jobs, and metrics. The backend routes downloads through a provider registry to either a yt-dlp adapter for YouTube or an instagrapi adapter for Instagram, the latter backed by a secure session store holding the login session and TOTP secret.Architecture diagram: a Flutter client on Android, iOS, Linux, macOS, Windows, and web sends requests through an Nginx reverse proxy, which also serves the static Flutter web build. Nginx forwards API traffic to a FastAPI backend, which reads and writes a SQLite database and exposes an admin scope for status, jobs, and metrics. The backend routes downloads through a provider registry to either a yt-dlp adapter for YouTube or an instagrapi adapter for Instagram, the latter backed by a secure session store holding the login session and TOTP secret.Architecture diagram: a Flutter client on Android, iOS, Linux, macOS, Windows, and web sends requests through an Nginx reverse proxy, which also serves the static Flutter web build. Nginx forwards API traffic to a FastAPI backend, which reads and writes a SQLite database and exposes an admin scope for status, jobs, and metrics. The backend routes downloads through a provider registry to either a yt-dlp adapter for YouTube or an instagrapi adapter for Instagram, the latter backed by a secure session store holding the login session and TOTP secret.Architecture diagram: a Flutter client on Android, iOS, Linux, macOS, Windows, and web sends requests through an Nginx reverse proxy, which also serves the static Flutter web build. Nginx forwards API traffic to a FastAPI backend, which reads and writes a SQLite database and exposes an admin scope for status, jobs, and metrics. The backend routes downloads through a provider registry to either a yt-dlp adapter for YouTube or an instagrapi adapter for Instagram, the latter backed by a secure session store holding the login session and TOTP secret.Architecture diagram: a Flutter client on Android, iOS, Linux, macOS, Windows, and web sends requests through an Nginx reverse proxy, which also serves the static Flutter web build. Nginx forwards API traffic to a FastAPI backend, which reads and writes a SQLite database and exposes an admin scope for status, jobs, and metrics. The backend routes downloads through a provider registry to either a yt-dlp adapter for YouTube or an instagrapi adapter for Instagram, the latter backed by a secure session store holding the login session and TOTP secret.

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.