Skip to main content

Grabbit

A media downloader that never offers a format it cannot deliver

Timeline
Sep 2026
Reading Time
7 min read
PythonFastAPIyt-dlpffmpegNext.jsSQLiteDocker

126

Backend tests

15

Supported sources

0.45–0.71s

Cold start

How these numbers were measured: CPU and cold-start times measured on real jobs. The storage figure is the worst-case cap set by the default limits.

The Constraints I Set

Four rules shaped every later decision, and each one closed off an easier design.

Keeping a public, unauthenticated API cheap also set the default limits. One file is capped at 500 MB, a playlist job at 10 items and a job at 600 seconds, and one IP can create 5 jobs a minute. That took the worst case for a single job from 100 GB under my original defaults to 4.9 GB.

  • Never show an option the source cannot provide: every quality, frame rate and format is discovered per link, never declared.
  • Temporary storage only: a file deletes itself 15 minutes after you save it, or 180 minutes if you never do.
  • No accounts and no circumvention: no login, and nothing that bypasses DRM, paywalls or authentication.
  • Cheap and safe to run in public: the API has no authentication, so anyone with the URL spends my egress.

Architecture

The two halves deploy independently. The frontend is a Next.js App Router site with five static routes and no SSR, API routes or middleware. The backend is FastAPI in a self-contained Docker image with ffmpeg, SQLite in WAL mode on a mounted volume, and serverless sleep switched on. Media never passes through the frontend host: files stream straight from the API to the browser.

A download is two calls. POST /api/v1/inspect validates the URL, runs yt-dlp behind a cache and a circuit breaker, and returns only what this media and this server's ffmpeg can both produce. POST /api/v1/jobs re-validates the chosen options, checks disk capacity and queues the job, and the browser polls every 1.5 seconds until the file is ready.

  • Platform registry: 15 sources, matched on exact or suffix hostnames so that evil.com/reddit.com is not treated as Reddit.
  • Capability probe: runs ffmpeg -encoders once per process and reports what this machine can actually encode.
  • Inspection service: turns a raw yt-dlp info dict into a quality ladder, frame rates, audio formats, chapters, subtitles and playlist items.
  • Job service: validate_choice() rejects any option that inspection did not find.
  • State machine: the only code allowed to change a job's status, driven by a declared transition table.
  • Executor: 2 job workers, each running at most 2 playlist items, so no more than four yt-dlp processes run at once.
  • Storage: per-job directories, traversal-safe paths, capacity checks and atomic filename claims.
  • Resilience: a per-platform circuit breaker (5 failures, 60-second cooldown), a 300-second LRU inspection cache, and retries classified as permanent, rate-limited or transient, with exponential backoff and jitter.

Why Discovered Options Over a Hardcoded Menu

The simple design was a per-platform list of qualities and formats in the frontend. It is quick to ship, but it is wrong for every video that does not match the list. I chose to derive the entire form from the inspection response, intersected with the ffmpeg probe. If a video has no 4K rendition, no 4K option exists; if ffmpeg is missing, audio conversion disappears from the UI instead of failing at download time.

The trade-off is an inspection round trip before anything can be chosen, about 3.5 seconds for a YouTube video in my local test, and more complex UI state. It paid off when I expanded coverage: adding a platform became mostly a registry entry and a logo, which is how the count went from 7 to 15 in one phase.

It also exposed a subtle bug. In yt-dlp, a vcodec of None means the codec is unknown, while the string "none" means there is no video track, and my code treated them the same, discarding working video from Twitch, Rumble and Imgur. For audio I applied the distinction conservatively, because guessing wrong offers a download that cannot work: offer it if any format declares an audio codec, or if nothing declares anything at all.

Why Playlist Indices Over Client-Supplied URLs

For per-item playlist selection, the obvious design is for the browser to send the URLs of the items you ticked. That is a server-side request forgery hole: the server would fetch whatever URL it was handed. Instead the client sends positions, and the server re-resolves the playlist itself and picks by index. This sits alongside URL validation that rejects embedded credentials, local hostnames and any host resolving to a private, loopback or link-local address.

The cost is that inspect and download must enumerate a playlist identically, and at first they did not. The download path skipped entries without a resolvable URL while inspect counted them, so indices could drift and you would get the wrong tracks. Both now share one enumeration, and a test pins the alignment against real data.

The Hardest Problem: Races That Lost Files

When I moved playlist items from a sequential loop to a bounded thread pool, two data-loss bugs surfaced. The first was in unique_name(), which checked whether a filename was free and then used it, so two items with identical titles could resolve to the same path and the second overwrote the first. I replaced the check with an atomic claim, opening the file with O_CREAT and O_EXCL under a lock and moving to "Title (2)" on collision.

The second bug was in progress counters, computed under a lock but written outside it. A stale count could land last, so a five-item playlist could finish reporting four. I serialised the writes and added one authoritative write after the pool drains, and both fixes are now pinned by tests.

The same work pushed me to replace scattered status checks with an explicit state machine, because a worker that had not noticed its job was finished could overwrite a completed job as cancelled. Every status change now goes through a transition table that refuses and logs illegal moves. Workers claim jobs with a conditional UPDATE on status = 'queued' and check the row count, because SQLite ignores SELECT FOR UPDATE. One limitation remains: concurrent playlists are proven against a stub provider rather than live YouTube, because cookie access was blocked in my build environment.

Results

Grabbit is live at grabbit.taaranjain.com. It supports 15 sources, video from 144p to 4K, clip ranges, chapter splitting, subtitles, ID3 tags with cover art, and a QR code for saving on a phone. I took it from a throwaway script to a deployed full-stack app in one day, 19 September 2026. The backend has 126 tests that pass in about 12 seconds, covering the job lifecycle, SSRF, path traversal, injection and concurrency.

I measured cost rather than guessing. Converting a minute of audio to tagged MP3 costs about 2 seconds of CPU, while a 60-second 360p clip with exact cuts costs 23.5 seconds against 0.3 without them, a 74× difference driven by ffmpeg's force_keyframes_at_cuts. A cold start after serverless sleep measured 0.45 to 0.71 seconds.

What is missing is deliberate too. I left out TikTok because every request from my machine failed, even with curl_cffi TLS impersonation, and TikTok blocks datacenter IPs, so it could fail in production even if it worked locally. A feature that works for the developer and breaks for users is worse than no feature.

What I Would Do Differently

First, I would test the contract between frontend and backend, not just each side. The frontend has no test runner, and while capturing screenshots for this write-up I found that the API serialises expires_at as UTC without a time zone marker. The browser reads it as local time, so in India, at UTC+5:30, the countdown shows the file as already deleted the moment a download finishes. Time-zone-aware timestamps end to end, plus a few contract tests, would have caught it.

Second, I would deploy a production image on day one. I relied on the host's automatic builder with a custom build command, and it installed dependencies into one interpreter path while the runtime resolved another, producing "No module named uvicorn" in a crash loop. It took eight deploy-related commits in 24 minutes before I switched to a pinned python:3.12-slim Dockerfile that reproduces production anywhere.