I wanted my own music on my phone, playable on a plane, without paying a subscription for the privilege. That part is ordinary. The constraint I set myself was the interesting bit: it had to run for nothing, and not the kind of nothing that turns into a bill in three months.
Worth saying first. The underlying tool is lawful and this code is MIT licensed, but fetching audio from a streaming platform breaches its terms of service, and the private-copying exception that exists across the EU, France included, where I live and where this was built, does not extend to circumventing a technological protection measure. A German appeals court settled that point for this class of tool in November 2024.
I publish this as an engineering write-up, for personal and educational use. The longer notice is in the repository. It is not legal advice.
The downloader pulls, it is not pushed to
Fetching and remuxing audio needs real binaries, and a Workers isolate cannot run those. The obvious answer is Cloudflare Containers. It is the wrong answer twice: Containers have no free tier at all, and they leave through Cloudflare IP ranges, which is exactly what the source platform's bot filtering watches for.
A Worker also cannot call a machine sitting behind a home router. No fixed address, no open port. Instead of tunnelling through that, I turned the call around.
Every connection is now outbound, so there is nothing to open on the router. Nothing runs on Cloudflare, so nothing leaves the free tier. And the request to the platform leaves from a home connection rather than a datacenter range, which turns out to be the difference between working and being filtered. The trade-off is real and I will not dress it up: my machine has to be on for a job to run. Jobs sit in the queue and drain at the next boot. Playback never waits on it, since the files already live in object storage.
Claiming a job without racing
Pulling only works if two workers cannot grab the same job. The claim is a conditional update, and the second one to arrive sees zero rows changed and walks away empty handed:
UPDATE tracks SET status = 'downloading', claimed_at = ? WHERE id = ? AND status = 'pending'
Claims also carry a lease. Anything reserved but silent for fifteen minutes goes back in the queue, which is what saves a job from a power cut, a container stopped mid-download, or a laptop lid closing. Progress reports push the lease forward, so a slow job is never preempted by its own slowness.
Offline was the point, not a feature
Tracks are stored through the Cache API and played from a blob: URL. The service worker is
deliberately kept away from audio: Safari probes every <audio> element with a one-byte
range request, and a worker that helpfully answers with the whole file breaks playback without raising
anything you can catch.
The part I did not see coming is that the list has to be local too. Every file can be on the device and the app will still open to an empty library, because knowing which tracks exist is an API call, and that call is the one thing a plane guarantees will fail. The last good response is now kept locally and used as the fallback.
The service worker was deleting every cache absent from its keep-list, and the audio cache is created by the page. So each update quietly wiped every downloaded file. The app looked perfect and was empty at precisely the moment it was supposed to earn its keep.
Small things Safari and Chrome insist on
Opus only plays inside an Ogg container, so everything is remuxed into one, stream-copied, with no re-encoding loss. Chrome caps every cookie at 400 days no matter what the server asks for, so sessions quietly re-issue themselves past thirty days and a device is never locked out. iOS ignores the orientation field in the web app manifest, and Safari has no API to lock it, so the portrait lock lives in CSS. None of these are hard. All of them fail silently, which is worse.
Adding a track without leaving the page
Pasting links by hand got old within a week, so there is a small Chrome extension that puts a button in the page. The content script never talks to my Worker directly. It messages the extension's own service worker, which is what lets the extension avoid asking for permission to read the pages you visit.
What it costs
- Workers
- ~2,000 of 100,000 requests a day
- D1
- a few thousand of 5M reads a day
- R2
- ~1,500 tracks inside 10 GB, egress free
- The downloader
- about 3 W on a Raspberry Pi
The first line that could ever tip into paid is storage past ten gigabytes, which on my listening habits is a problem for another year.