DEV Community

Shaarav Agarwal
Shaarav Agarwal

Posted on

I automated my 3,163-song music library with Spotify's API. Then Spotify killed the API.

Context

When I was first learning to code, I built Apollo — a desktop music player that could download songs and show you their lyrics. It worked, and I kept it. But the real project turned out to be the library around it: over the years I accumulated 3,163 mp3s, and my folder of one-off scripts grew into a small ecosystem that downloaded music, embedded lyrics, found duplicates, and eventually tried to sort the songs that didn't fit anywhere into mood playlists automatically using Spotify's audio analysis as the spec. The last part worked for a while — until Spotify deprecated the very API it was built on. This is that story, and the honest ending.

Approach

The player. Apollo is tkinter + pygame. It plays mp3s, queues files from a .txt or .csv playlist, and has shuffle, repeat-one, and repeat-all — the classic feature set, written by someone who hadn't yet learned to reach for a framework. Two pieces were more ambitious: it could download a song by pasting a YouTube link (via pytube), and it could fetch lyrics for the current track (via the Genius API). That "download + lyrics" pairing was the seed of everything that came after.

Apollo — the tkinter/pygame music player with lyrics

The download pipeline. spotify playlist.py takes a Spotify playlist URI, pulls every track into a CSV (name, artist, Spotify URL), then for each line searches YouTube and downloads the result as a 192 kbps mp3 via yt-dlp. It even splits the file across all your CPU cores with multiprocessing — each core gets a slice of the song list and downloads in parallel. The glue between Spotify and YouTube is just the track name; it's astonishingly simple and astonishingly effective. To be explicit about scope: this ran against my own personal library, for my own listening — playlist reconstruction and organization, not distribution.

The segregation spec — the part that mattered. My library has two kinds of playlists. The first kind I made by hand, beforehand: potentones is literally "songs I could use as potential ringtones" — no amount of automation could have produced that playlist, because the criterion lives in my head, not in any audio feature. Same for quasar, robot seizure, wtf, vibe, karaoke. Those are deliberate human curation, and they're the ones that were never supposed to be auto-segregated.

The second kind is the batch that didn't fit anywhere. For those songs, I wrote energism.py and beatspermin.py, which use Spotify's audio_features API to get, for every local track: BPM, energy, valence, speechiness, danceability. That data became my spec. A song isn't "kinda upbeat" — it has a number. filter() buckets the batch by thresholds (speechiness ≤ 0.20 → one bucket, ≤ 0.40 → next, and so on) and moves files into playlist folders. In one run it produced buckets of 106 / 253 / 329 / 261 / 105 songs, named for what they measure: slow, mid_low_valence, balanced, upper mediocre, ecstasy.

The lyrics pipeline. Lyrics.py fetches lyrics from Genius, opens them in VSCode for a human review pass (the app waits for you to close the editor), then embeds them into the mp3 as an ID3 USLT frame via eyed3. It's checkpointed: a processed_songs.txt record (2,352 entries) tracks what's done, with separate files for "no lyrics found" and "unfound." The checkpoint files are what made the whole thing auditable.

Architecture

graph LR
    A[Spotify playlist] --> B[CSV spec<br/>name, artist, URL]
    B --> C[YouTube search<br/>per-core parallel]
    C --> D[yt-dlp → 192k mp3]
    D --> E[Local library<br/>3,163 mp3s]
    E --> F[Hand-made playlists<br/>potentones, quasar, wtf...<br/>human curation, no spec]
    E --> G[Unsegregated batch<br/>songs that fit nowhere]
    G --> H[Spotify audio_features<br/>BPM, energy, valence, speechiness]
    H --> I[Bucket by thresholds<br/>106 / 253 / 329 / 261 / 105]
    I --> J[Auto playlists<br/>slow, mid_low_valence, balanced...]
    E --> K[Lyrics pipeline]
    K --> L[Genius → VSCode review<br/>SYNCHRONOUS — script waits<br/>for editor to close]
    L --> M[USLT frame embed<br/>via eyed3]
    M --> N[processed_songs.txt<br/>checkpoint]

Evidence

The audit report tells the story with numbers, and the numbers are checkable. Of 3,163 mp3s, 2,352 were recorded as processed. Of the 791 songs with no lyrics, 670 (85%) sat in just 6 playlists — and those six are exactly the hand-made ones that were never supposed to be auto-segregated in the first place. The pipeline's coverage boundary shows up in the data:

Playlist Songs No lyrics % missing
robot seizure 279 258 92%
wtf 177 140 79%
vibe 121 97 80%
quasar 96 92 96%
potentones 59 52 88%
karaoke 35 31 89%

(For the arithmetic: 2,352 processed + 791 no-lyrics = 3,143 of the 3,163 files; the ~20-file gap is duplicates and near-misses that find_dupes.py exists to catch.)

The report also caught the edge cases: 11 songs recorded as "processed" whose files had no USLT frame at all (the "didn't make the transfer" bucket), and one duplicate where a copy with lyrics existed in another folder.

What went wrong

Spotify deprecated the API. The audio_features endpoints that the entire segregation spec was built on were deprecated, and the automation had to stop. The playlists I already sorted are mine to keep — but the pipeline that produces them is dead. Everything I built on that data was renting the ground it stood on.

The honest list. My teenage code has real receipts: YouTube search occasionally matched the wrong song, non-English titles blew up with UnicodeEncodeError (I handled it by skipping, not fixing), and Spotify's API returns local-only tracks that fail with a KeyError — which I also skip. And the segmentation itself was scoped to the unsegregated batch, by design: the hand-made playlists were never fed through it, which is exactly what the audit report later made visible — those six playlists are where 85% of the missing lyrics live, because the lyrics pipeline never ran over them either.

The spec was good. The source wasn't mine. The insight I'd actually want to keep — define a playlist by measurable features instead of by hand — is transportable. It could be rebuilt today with local analysis (essentia or librosa) instead of a vendor API, which is the whole lesson in one sentence.

Lessons learned

  • Building automation on a vendor API means the vendor owns your pipeline's life support. Spotify didn't break my app — it just stopped feeding the spec.
  • A good spec outlives the tool that consumed it. The feature-based playlist idea survives the API that powered it.
  • Automation at scale surfaces edge cases fast: encoding, duplicates, near-misses, local-only tracks. Expect them, log them, audit them.
  • Some organization can't be automated, and that's fine. "Potentones" — songs I could use as ringtones — is a judgment call that lives in my head, not in any audio feature. The spec handles what's spec-able; the rest stays human.
  • Know where your automation doesn't run. The hand-made playlists were out of scope by design, but I stopped noticing that — the audit made the boundary visible: 85% of missing lyrics live exactly where the pipeline never went.
  • Checkpoint files are a feature. processed_songs.txt turned "did I do this song?" from a guess into a lookup.

Links

Top comments (0)