Shokz OpenSwim headphones can't stream underwater, so I built a site that turns a Spotify or Apple Music playlist into a zip of tagged MP3s: reading playlists without an API key, finding the official audio instead of the music video, and running it all on my home server.
My Shokz OpenSwim headphones are great in the pool, with one catch: they can't stream. Bluetooth doesn't carry through water, so underwater they only play MP3 files that you've copied onto them over USB. My music lives in Spotify and Apple Music playlists, and you can't copy those onto anything. Rebuilding a playlist by hand meant tracking down and converting every song, one at a time.
So I built Swimlist. You paste a playlist (or album) link, and it hands you back a zip of tagged MP3s ready to drag onto the headphones. It went from an empty folder to a public site on my home server in a single session.
What it does
The server reads the playlist's track list, finds each song's official audio on YouTube Music (never the music video), downloads it and converts it to MP3, tags it with the playlist's own metadata, and packs everything into one zip. The page shows every song's progress live while it works.
Everything is TypeScript. A Hono server on Node 22 does the work, and a small React page built with Vite talks to it over a JSON API. youtubei.js searches YouTube Music, yt-dlp downloads the audio, and ffmpeg converts and tags it. One Docker container runs all of it behind the Traefik reverse proxy that already fronts everything on my server, which also handles HTTPS through Let's Encrypt.
Fact | Value |
|---|---|
Sources supported | Spotify and Apple Music, playlists and albums |
Output | Zip of VBR MP3s, about 190 kbps, 44.1 kHz, ID3v2.3 tags |
First end-to-end runs | 5/5, then 17/18, then 18/18 songs after a fix |
Runtime dependencies | Node 22, Python 3 (for yt-dlp), ffmpeg |
Hosting | One container on a Dell PowerEdge T320 home server, behind Traefik |
How it fits together
One Node process does everything: it serves the page, answers the API and runs a job queue that shells out to yt-dlp and ffmpeg. There's no database. Jobs live in memory, and their files live on a Docker volume for an hour.

A playlist moves through five stages:
- Read the playlist.
POST /api/jobsparses the link, fetches the track list from Spotify or Apple, and creates a job. Bad links fail right here, synchronously, so you see the error immediately. - Queue. The job waits for one of two global slots.
- Match. Three workers per job search YouTube Music for each song's official audio.
- Download and tag. yt-dlp fetches the audio, ffmpeg converts it to MP3, and a second ffmpeg pass writes the playlist's tags.
- Zip. The MP3s go into one uncompressed zip, and the page offers the download.
Meanwhile the browser polls GET /api/jobs/:id every 1.5 seconds and redraws the track list from the response.
The code is split by job: the server, the browser app, and the types they share.
shokz/
├── server/
│ ├── index.ts Hono app: API routes, static files, rate limit, yt-dlp updater
│ ├── config.ts Every setting, read from environment variables
│ ├── jobs.ts Queue, workers, zipping, expiry, per-IP limits
│ ├── match.ts YouTube Music search and scoring
│ ├── download.ts yt-dlp + ffmpeg wrappers, error messages
│ └── playlist/
│ ├── index.ts Picks a reader from the link
│ ├── spotify.ts Web API reader + embed-player fallback
│ ├── apple.ts Catalog API reader + page-data fallback
│ └── http.ts Browser-like fetch, JSON-in-<script> parser
├── shared/types.ts Playlist, track and job shapes used by both sides
├── web/
│ ├── index.html
│ ├── public/favicon.svg
│ └── src/ main.tsx, App.tsx, api.ts, styles.css
├── Dockerfile Two stages: build, then a slim runtime with ffmpeg + yt-dlp
├── docker-compose.yml One service plus Traefik labels
├── .env.example Every setting with a comment
├── tsconfig.server.json NodeNext modules, compiled with tsc to dist/server
├── tsconfig.web.json Bundler resolution, type-check only (Vite builds it)
└── vite.config.ts Builds web/ into dist/public, proxies /api in devThe one shared file is the API contract. shared/types.ts defines Playlist, TrackStatus and JobStatus, and both the server and the React app import it, so renaming a field on one side breaks the type check on the other.
I kept the dependency list short on purpose:
Package | Role |
|---|---|
hono, @hono/node-server | HTTP routing, static files, secure headers, body limit |
youtubei.js | YouTube Music search through YouTube's internal API |
archiver | Writing the zip |
react, react-dom, vite | The browser page (dev dependencies, bundled at build) |
typescript, tsx | Compiling, and running the server in watch mode during development |
Reading playlists without an API key
Neither service offers an easy, credential-free way to list a playlist's tracks, so each reader tries a rich source first and falls back to a simpler one. Before writing any code, I probed every option live from the server with curl to see what it actually returns in 2026:
Source | Credentials | Tracks returned | ISRCs | Result |
|---|---|---|---|---|
Spotify embed player | None | First 100 only | No | Works; the JSON sits in a |
Spotify Web API with the embed's anonymous token | Token scraped from the embed | None | No | Refused: |
Spotify Web API, client-credentials app | Client ID and secret | All, paged 100 at a time | Yes | Works for user playlists; editorial playlists return 404 |
Apple Music page ( | None | Most playlists | No | Works; title, artist, album and duration per track |
Apple catalog API ( | Public developer token from Apple's JS bundle | All, paged 100 at a time | Yes | Works |
ISRCs matter a lot. An ISRC is the industry ID of one specific recording, and YouTube Music can search by it, so the readers prefer whichever source provides them.
Spotify
The Spotify reader accepts open.spotify.com/playlist/<id> links, the /intl-xx/ variants, spotify:playlist:<id> URIs, spotify.link short links, and albums.
- With credentials (
SPOTIFY_CLIENT_IDandSPOTIFY_CLIENT_SECRET), it gets a client-credentials token and pages through/v1/playlists/<id>/tracks. Afields=filter trims each response to just the name, artists, album, duration and ISRC. It stops at the track limit (300) and records the playlist's real total, so the page can say "only the first 300 are included". I reused the credentials from an older Spotify side project on the same machine. - Editorial playlists like Today's Top Hits (ids starting with
37i9dQZF) return 404 to apps created after Spotify's November 2024 API changes. Any 404 or error falls back to the embed. - Without credentials, it reads the embed player's server-rendered JSON. That gives title, artists and duration, but it caps at 100 tracks and has no ISRCs.
- Private or deleted playlists still answer 200 from the embed, just without a playlist in the response. The reader treats "page parsed, no playlist in it" as "not found or private", not as a parser failure. Testing a fake playlist ID caught this one.
Apple Music
The Apple reader accepts music.apple.com/<storefront>/playlist/<slug>/pl.<id> links and album links, and keeps the storefront (us, gb and so on) for the API call.
- It fetches the public page first, because both strategies need it.
- Catalog API: the page's main script bundle (
/assets/index~<hash>.js) contains a public developer JWT for Apple's own web player. The reader extracts it, reads itsexpclaim so it can cache the token until it expires, and pages through/v1/catalog/<storefront>/playlists/<id>/tracks, sendingOrigin: https://music.apple.com. - Fallback: if the token or the API fails, it parses the
serialized-server-dataJSON in the page itself, walking the page's sections for items whose id starts withtrack-lockup. - Artists stay whole. Apple joins credits as "A, B & C". Splitting that string would turn "Simon & Garfunkel" into two artists in the ID3 tag, so the reader keeps the string intact and only splits it inside the matcher.
A few choices apply to both readers:
- Look like a browser. Page fetches send a desktop Chrome user agent and an
Accept-Languageheader, since both sites only serve the full server-rendered data to browsers. Every request has a 20-second timeout. - Two kinds of error. A
UserError(bad link, private playlist) becomes an HTTP 400 with a friendly message. Anything else is logged with its stack trace and returned as a generic 502. - Albums for free. The embed and catalog APIs use the same shapes for albums, so supporting them only took a few lines.
Official audio, not the music video
My one hard requirement was the official audio of every song, never the music video with its intro skits and sound effects. The key insight is that YouTube Music's Songs search filter already does most of this. It only returns label-delivered audio, the "Artist - Topic" uploads you hear in the YouTube Music app. Music videos live under a separate Videos filter.
Searching with youtubei.js
yt-dlp can search YouTube Music too, but in flat mode each result carries only an ID and a title. There's no duration, artist or album, which is too little to tell the right song from a cover. youtubei.js talks to YouTube's internal InnerTube API and returns full song rows: video ID, title, artists, album and duration. It's written in TypeScript and was updated as recently as September 2026. So the split is simple: youtubei.js searches, yt-dlp downloads.
One Innertube client is created lazily and shared. If a search throws (an expired session, for example), the client is rebuilt and the search retried once.
The search strategy
- ISRC first. If the source gave an ISRC, search YouTube Music for that bare code. The top song result is the exact recording; anything after it is unrelated filler, so only the top one counts. A probe for
USUM72606032returned ADÉLA's "Nicole Kidman" first. - Text search. Search Songs for
<primary artist> <cleaned title>and score the top 10 results. - Video fallback. Only if no song passes: search Videos for
<artist> <title> official audio. Anything whose title says "official video", "music video", "MV" or "visualizer" is skipped outright. Titles mentioning "audio", "topic" or "lyrics" get a small bonus. - Keep three. The best three acceptable matches are returned, ISRC hit first, so a blocked download can fall back to the next one.
A cleanTitle() helper strips decorations that differ between services, like (feat. X), [Explicit], - Remastered 2011 and - From "Footloose".
Scoring candidates
Each candidate gets a score from 0 to about 1:
score = 0.45 × title + 0.30 × artist + 0.25 × duration − variant penaltyPart | How it's measured |
|---|---|
Title | Sørensen–Dice similarity over character bigrams of the normalized titles; 0.85 if one title contains the other |
Artist | 0.75 plus up to 0.25 for the share of credited artists found. If no artist matches, the whole score is 0. |
Duration | 1.0 within 3 s, 0.8 within 8 s, 0.5 within 15 s, 0.2 within 30 s, else 0; 0.5 when a duration is unknown |
Variant penalty | −0.3 if the candidate is a remix, live, cover, karaoke, instrumental, acoustic, sped up, slowed, nightcore, demo, edit or re-recorded version and the source isn't, checked against both title and album |
Normalization lowercases everything, strips accents, turns & into "and" and drops punctuation. Artist matching also compares names with the spaces removed, so a channel called "BryanAdamsVEVO" still counts as Bryan Adams. Combined credits like "KAROL G, Judeline & rusowsky" are split on commas, &, "feat.", "with" and "x" before matching. The cut-offs are 0.5 for an ISRC hit, 0.6 for a Songs result and 0.55 in the fallback round.
The songs that broke it
I tuned the first version against real playlists, Spotify's All Out 80s and Apple's Today's Hits. Most songs matched with a perfect 1.00 on the first try. These didn't:
Song | First match | Cause | Fix |
|---|---|---|---|
Bonnie Tyler, "Total Eclipse of the Heart" (267 s on Spotify) | A "Re-recorded / Remastered" version, 275 s | The originals on YouTube Music are 418 s and 335 s, so the re-record won on duration | Penalize "re-recorded"; the original album recording now wins |
Bryan Adams, "Heaven" | PelleK's cover of "Heaven" | The original isn't in YouTube Music's Songs results, and a same-titled cover scored 0.65 on title and duration alone | A wrong artist now scores 0; the video fallback finds Bryan Adams' own 241 s audio upload |
KAROL G, Judeline & rusowsky, "BbY WOW" | Correct song, low score (0.70) | Apple's whole credit string never matched as one artist | Split combined credits, for matching only |
Pitbull, "Everybody Fucks" | Correct song, but the download failed | Age-restricted on YouTube, which needs a signed-in account | Ranked alternates: it fell back to the censored upload, and the album went from 17/18 to 18/18 |
Downloading, tagging and packaging
Each song takes about 14 seconds on the T320, and most of that is the MP3 encode rather than the download. Two child processes run per song: yt-dlp, then a quick ffmpeg retag.
yt-dlp --no-playlist --no-progress --no-warnings --no-mtime \
--js-runtimes node \
--retries 3 --socket-timeout 30 \
--cache-dir /data/cache/yt-dlp \
-f bestaudio/best -x --audio-format mp3 --audio-quality 2 \
--postprocessor-args "ExtractAudio:-ar 44100" \
-o "<song dir>/.dl-<track number>.%(ext)s" \
https://music.youtube.com/watch?v=<id>--js-runtimes nodeis new in 2026. yt-dlp now needs an external JavaScript runtime to solve YouTube's player challenges, and it defaults to Deno only. The container already has Node, so I point it there.--audio-quality 2is LAME's VBR V2, about 190 kbps. The source is usually around 130 kbps Opus, so V0 would only make files bigger, and OpenSwim storage is limited (4 GB on the original model). It's configurable asMP3_QUALITY.-ar 44100resamples from YouTube's 48 kHz to 44.1 kHz, the safest rate for small MP3 players.- Temp names use the track number, not the video ID, because a playlist can list the same song twice and two workers would collide.
- Arguments are an array passed to
spawn(), never a shell string, so song titles can't inject anything. - Timeouts: 10 minutes for yt-dlp and 2 for ffmpeg, then
SIGKILL. Cancelling a job kills the running process through anAbortSignal. - Cleanup: a
finallyblock deletes every.dl-<n>leftover (.webm,.part) whether the step worked, failed or was cancelled.
Tags from the playlist, not from YouTube
YouTube's metadata is often messy, with "Re-recorded" in titles and channel names standing in for artists. A second ffmpeg pass copies the audio untouched (-c copy, in under a second) and rewrites the tags from the Spotify or Apple data:
ffmpeg -i raw.mp3 -map 0:a -c copy -map_metadata -1 \
-metadata title=... -metadata artist=... -metadata album=... -metadata track=3/18 \
-id3v2_version 3 -write_id3v1 1 tagged.mp3ID3v2.3 plus an ID3v1 tag is the most compatible combination for older players. I considered writing the tags in the same yt-dlp call through --postprocessor-args, but yt-dlp splits those arguments shell-style, so a title with quotes in it would need escaping. A separate ffmpeg step with plain arguments sidesteps that entirely.
File names and play order
Shokz headphones play files in the order they were copied onto the device, not alphabetically. Copying a whole folder goes in name order, so numbering the files keeps the playlist's order:
All Out 80s/
01 - Madonna - La Isla Bonita.mp3
02 - Bonnie Tyler - Total Eclipse of the Heart.mp3
...
Missing songs.txt- The number is zero-padded to the playlist's width: 2 digits up to 99 songs, 3 above that.
- Names are made safe for the FAT32 card inside the headphones. Reserved characters and control characters become spaces, trailing dots and spaces are trimmed, and names are capped at 150 characters.
The zip
- It's made with
archiverin store mode (no compression). MP3s don't shrink any further, and store mode is instant. - The songs sit in a folder named after the playlist, so unzipping gives you one folder to drag onto the headphones.
- If any songs failed,
Missing songs.txtat the zip's root lists each one and why. It sits outside the folder so it never gets copied onto the device. - The zip is built on disk once and the loose MP3s are deleted. Building the file first means the download has an exact
Content-Length, so the browser shows real progress. - The download sends both
filename=(ASCII) andfilename*=UTF-8''..., so playlist names like "Today’s Hits" survive.
A 5-song test zip came out at 46.9 MB, and an 18-song album at 95.9 MB.
A job queue for a public site
A conversion takes minutes, so the API is job-based: create a job, poll its status, download the result. Four job endpoints and a health check cover it, all JSON except the zip.
Method and path | Does | Errors |
|---|---|---|
| Reads the playlist, queues a job and returns its status (201) | 400 bad or private link, 429 limits hit, 502 source unreadable |
| Current status: state, queue position, per-track progress, counts, zip size, expiry | 404 expired or unknown |
| Cancels a queued or running job | 404 |
| Streams the zip with | 409 not ready, 404 |
| | None |
Job IDs are random UUIDs. Knowing the ID is the only access control, which is enough for an anonymous download link that only lives for an hour.
I went with polling rather than WebSockets or Server-Sent Events. Polling every 1.5 seconds is boring and robust: it passes through Traefik with no special timeouts, survives a laptop going to sleep, and needs no reconnect logic. The status object is small, a few KB even for 300 tracks.

A job only fails outright when none of its songs download; songs that fail one by one are listed in the zip instead. Cancelling a running job aborts its yt-dlp processes and deletes its files at once.
Concurrency
- Two levels of parallelism. At most two playlists (
MAX_CONCURRENT_JOBS) convert at once, and the rest wait in a FIFO queue where they can see their position. Inside a job, three workers (TRACK_CONCURRENCY) pull songs off a shared index. That caps the server at six yt-dlp and ffmpeg pairs, a sensible load for the T320's 6-core Xeon. - No library for it. The pool is a dozen lines: workers loop
while (next < total) { const i = next++; ... }, and Node's single thread makes the shared counter safe. - Order is kept by writing each finished file into its slot in an array, so the zip is in playlist order no matter which downloads finish first.
Keeping it healthy
Anyone on the internet can use the site, and every job costs CPU, bandwidth and disk. A handful of limits keep that in check, all set from environment variables:
Limit | Default | Why |
|---|---|---|
Songs per playlist | 300 | Bounds one job's time and disk; longer playlists are cut, with a note on the page |
Playlists in progress per IP | 2 | One visitor can't fill the queue |
Queued and running playlists in total | 20 | Past this, new requests get "The server is busy" |
Playlist lookups per IP | 20 per 10 minutes | Each lookup hits Spotify or Apple; this stops anyone using the server to hammer them |
Request body | 4 KB | A URL is all the API needs |
Zip lifetime | 60 minutes after finishing | A sweep every minute deletes expired jobs and their files |
Abandoned jobs | Cancelled after 15 minutes without a status poll | Nobody is waiting for them any more |
The visitor's IP comes from Traefik's X-Real-Ip header, since Traefik is the only way in. On startup the server wipes its jobs folder; jobs live in memory, so files from before a restart could never be downloaded anyway.
yt-dlp's raw errors are long and technical, so a describeError() helper maps them to short reasons for the page and for Missing songs.txt, while the logs keep the full text:
yt-dlp says | The page says |
|---|---|
"Sign in to confirm your age" | Age-restricted on YouTube |
"Sign in to confirm you're not a bot" | YouTube is blocking downloads right now (bot check) |
"not available in your country" | Blocked in this region on YouTube |
"Video unavailable", "Private video" | Unavailable on YouTube |
Killed by the timeout | Download timed out |
Hono's secureHeaders middleware sets a strict Content Security Policy too. Scripts, styles and API calls only come from the site itself. Images are also allowed from Spotify's and Apple's CDNs, because the page shows the playlist cover straight from them.
The page
The frontend is one React component tree of about 300 lines, built with Vite into a 227 KB script (71 KB gzipped) and a 5.6 KB stylesheet. The Hono server serves those files itself, so there's no separate web server.
- Paste a link. A single URL field and a "Make my MP3s" button. The hint underneath says which links work and where to find them (Share, then Copy link).
- Watch it work. A card shows the playlist's cover, name and owner, a headline ("Waiting in line — 2 ahead of you", "Finding and downloading songs…", "Packing your zip…"), a progress bar, and every track with a status pill: Waiting, Finding, Downloading, Ready or Not found. The pill of a matched song links to the YouTube Music upload it used, so a wrong match is easy to spot.
- Download. "Download zip · 96 MB", along with the time the file will be deleted.
Below that, a short how-to explains getting the songs onto the headphones: unzip, plug in with the charging cable (OpenSwim shows up as a USB drive), copy the folder and eject. It also explains the copy-order quirk.
A few small decisions made a big difference:
- Resume after a reload. The current job's ID is saved in
localStorage, so closing the tab or refreshing picks the job back up. Every storage call is wrapped intry/catch, because private browsing can throw, and the page still works without it. - Polling that backs off. Every 1.5 seconds while a job runs; a network error retries after 5 seconds; a 404 means the job expired, so the saved ID is cleared.
- Errors in plain words. The API returns
{ error: "..." }messages written for people, and the page shows them as they are. - The name. The site is called Swimlist rather than borrowing the Shokz brand. It only mentions Shokz as the headphones it's for.
- Accessibility. The job card is an
aria-liveregion, the bar hasrole="progressbar"with values, focus rings are visible, and the pulsing pills stop underprefers-reduced-motion.
The design is deliberately light. Colours are CSS custom properties, redefined for dark mode, with a single pool-water teal accent (#0b6e8a in light mode, #3ab8d6 in dark). A system font stack and a 760 px column keep it readable, and at phone width the input and button stack so the page never scrolls sideways. The favicon is an inline SVG of a music note over two waves.
For development, npm run dev:server runs the API with tsx watch on port 3000, and npm run dev:web runs Vite on 5173 and proxies /api to it. TypeScript 7 type-checks both halves with separate configs: NodeNext modules for the server, and bundler resolution plus DOM types for the page.
Shipping it on my home server
The whole site is one container, reached only through the Traefik reverse proxy that already fronts every project on my server. No port is published on the host.
The Dockerfile has two stages. The build stage (node:22-bookworm-slim) runs npm ci with a BuildKit cache mount, then npm run build (Vite for the page, tsc for the server) and npm prune --omit=dev. The runtime stage installs ffmpeg, python3 and python3-venv from Debian, creates a Python virtualenv at /opt/yt-dlp and pip installs yt-dlp[default], then copies in only dist/, the production node_modules and package.json.
- Debian slim, not Alpine. My other sites use
node:22-alpine, but this one needs Python and ffmpeg, and yt-dlp's optional speed-ups ship as manylinux wheels that don't install on Alpine's musl. yt-dlp[default]pulls inyt-dlp-ejs, the scripts that solve YouTube's JavaScript challenges, which Node then runs.- A virtualenv owned by the app user. The server runs as the unprivileged
nodeuser, and because it owns/opt/yt-dlp, it can upgrade yt-dlp in place without root. - A health check calls
/api/healthwith Node's built-infetchevery 30 seconds, so Docker and Traefik know when it's ready.
Keeping yt-dlp current
YouTube changes its player often, and old yt-dlp releases stop working every few weeks. So the server updates yt-dlp itself. On start, and then every 24 hours, it runs pip install --upgrade "yt-dlp[default]" in the virtualenv and logs the version before and after. It waits for an idle moment with no active jobs, and otherwise checks again in 10 minutes, because replacing Python files under a running download could crash it mid-song. YTDLP_AUTO_UPDATE=false turns it off, and /api/health reports the version in use.
name: shokz
services:
web:
build: .
restart: unless-stopped
init: true # reaps yt-dlp / ffmpeg child processes
environment: { ...limits, Spotify keys, MP3_QUALITY... }
volumes:
- work:/data # jobs, zips, yt-dlp cache
- ./config:/config # optional YouTube cookies.txt
networks: [default, traefik-public]
labels: { ...Traefik, below... }
logging: { driver: json-file, options: { max-size: 10m, max-file: "3" } }
volumes:
work:
networks:
traefik-public: { external: true }init: trueruns a tiny init process as PID 1. Without it, killed yt-dlp and ffmpeg processes can linger as zombies.- A named volume for
/data. The files are temporary, but writing gigabytes into the container's own layer would grow it until the container is recreated. - Two networks.
defaultgives outbound internet access for Spotify, Apple and YouTube, andtraefik-publicis the shared network where Traefik can reach the container. - Capped logs, 3 × 10 MB, like every other container on the server.
- Settings in
.env. Every limit has a default in compose and a commented line in.env.example. The real.envis git-ignored andchmod 600, since it holds the Spotify secret.
The Traefik side is a handful of labels:
traefik.enable: "true"
traefik.docker.network: traefik-public
traefik.http.routers.shokz.rule: Host(`${SITE_DOMAIN}`)
traefik.http.routers.shokz.entrypoints: websecure
traefik.http.routers.shokz.tls.certresolver: letsencrypt
traefik.http.services.shokz.loadbalancer.server.port: "3000"
# plus an HTTP router on :80 whose only job is a permanent redirect to HTTPS- DuckDNS points
*.torta-server.duckdns.orgat my home IP, so any new subdomain resolves with no DNS work. - Traefik gets a Let's Encrypt certificate over the HTTP-01 challenge on the first request. Mine was issued within about 20 seconds of the container starting.
traefik.docker.networkmatters: the container is on two networks, and without it Traefik might try the one it can't reach.- Unlike the client sites on the same server, this one sends no
X-Robots-Tag: noindex, since it's meant to be public.
A few days after launch I hardened the server's Traefik: v2.11, Docker access through a socket proxy, exposedbydefault=false, a global HTTPS redirect, HSTS and a per-IP rate limit. Swimlist kept working unchanged. Its own redirect router is redundant now, but harmless.
Testing against the real world
Testing went in four layers, from cheap to real, and every layer ran against live services, since the whole project depends on how Spotify, Apple and YouTube behave today.
- Probes before code. curl and short Python scripts against each data source (that's where the table above came from), plus throwaway Docker containers to try yt-dlp: first
python:3.12-slimfor search, thennode:22-bookworm-slimwith ffmpeg for a real MP3 download. That download is where the 14-seconds-per-song figure and the--js-runtimes noderequirement came from. - Matcher smoke tests. A small script ran the real playlist readers and matcher with
tsxand printed each source track next to its match, score and duration. This is where the Bonnie Tyler, Bryan Adams and KAROL G problems showed up. - End to end through the public URL. Real jobs created with curl against the live site, polled to completion, with the zip downloaded and inspected using
unzip -landffprobe. - Browser checks. A Playwright script in Microsoft's
playwright:v1.63.0-nobleimage drove the live site: desktop in light mode, phone-sized in dark mode, a bad link, a real album, a reload mid-job and the final download button, collecting console errors along the way.
Test | Result |
|---|---|
Spotify user playlist, 5 songs (Web API path, with ISRCs) | 5/5, 46.9 MB zip; tags and 44.1 kHz / 191 kbps confirmed with ffprobe |
Spotify album, 18 songs (Pitbull, Global Warming), first run | 17/18; one age-restricted song failed |
Same album after ranked alternates | 18/18, 95.9 MB |
Apple Music playlist, 50 songs, cancelled after 20 s | State went to |
Link from an unsupported site | 400 "Paste a Spotify or Apple Music playlist (or album) link." |
Fake Spotify playlist ID | First a 502 with a stack trace in the log; after the fix, 400 "Spotify couldn't find that playlist. Is it public?" |
Fake Apple playlist ID | 400 "Apple Music couldn't find that playlist. Is it shared publicly?" |
Reload in the middle of a job | The page picked the job back up |
Phone width, dark mode | No sideways scroll and no console errors, apart from the deliberate 400 |
I didn't write a unit test suite. For a project this size, the end-to-end runs were the tests that mattered, but the scorer (scoreCandidate and cleanTitle) is the first thing worth covering, with the songs above as fixtures.
What I learned
- Probe before you design. Two of my plan's assumptions died in the first ten minutes: Spotify's anonymous token can't call the Web API, and yt-dlp's flat search is too thin to match on. curl found both before any code depended on them.
- The right search filter beats a clever scorer. Restricting search to YouTube Music's Songs did most of the "official audio, not the video" work. The scorer only has to separate the original from covers and re-releases.
- ISRCs are the cheat code. When a source provides one, matching stops being fuzzy.
- Test with real playlists, not made-up songs. An 80s playlist surfaced re-recordings and missing originals, a current hits playlist surfaced multi-artist credits, and an explicit album surfaced age restrictions.
- "Not found" often isn't an error. Spotify answers 200 for a private playlist; you have to read the body to tell.
- Design for the device. Play order by copy order, FAT32-safe file names, 44.1 kHz, ID3v2.3: those are the details that make the files work on a cheap MP3 player, not just on a laptop.
- Public means limits from day one. Per-IP caps, a queue, expiry and abandonment cost little to build and protect the server.
Known limits
- Spotify editorial playlists (Today's Top Hits and the like) stop at 100 songs, since only the embed can read them.
- Age-restricted songs with no clean alternate fail without a YouTube cookies file. The
YTDLP_COOKIEShook exists, but I haven't set it up. - YouTube bot checks. A home IP rarely triggers them, but if YouTube starts asking "confirm you're not a bot", every download fails until cookies are added.
- Unofficial interfaces. The Spotify embed JSON, Apple's scraped developer token and YouTube's internal API can all change without notice. Each reader has a fallback and yt-dlp updates daily, but a change could still break a source until the code catches up.
- In-memory jobs. A restart drops running jobs and their downloads.
The legal and ethical side
I want to be upfront about this part. Downloading songs from YouTube sits in a copyright grey area and is against YouTube's terms of service, even for music you already pay to stream. Swimlist says "For personal listening only" and deletes every zip after an hour, but it's open to anyone who finds the address. I built it to get my own playlists into the pool, and I'm still deciding whether the public site should stay open or move behind a login.
What's next
- Cache MP3s by video ID, so a song shared by many playlists only downloads once.
- Optional loudness normalization with ffmpeg's
loudnorm, so quiet and loud tracks sit at the same volume in the pool. - A "fix this match" control that lets you paste a different YouTube link for one song.
- Unit tests for the scorer, using the problem songs above as fixtures.
For now, it does the one thing I needed: I paste a playlist, wait a few minutes, drag one folder onto my headphones, and go swim.
