When Spotify deprecated audio_features and audio_analysis, the obvious loss was the per-track numbers — tempo, key, energy. The quieter loss was everything you used to build on top of them: the "what should I play next" logic, the harmonic-compatibility checks, the auto-ordered playlist. Plenty of people have written a host-swap guide to get the raw fields back. This post is about the layer above that — turning a crate of track names into an actual, beat-matched, key-compatible set.
We'll do it in about 50 lines of Python against the FreqBlog Music API, using four endpoints: /next-track (what mixes well after this?), /transition (how well do these two mix, exactly?), /setlist (order this whole crate into an energy arc), and /export/rekordbox (drop the result straight into Rekordbox or Serato). No audio files, no Spotify ID — you pass track names or catalog IDs.
New here? If you haven't migrated off Spotify's removed endpoints yet, start with Spotify Audio Features Is Dead. Here's What to Use Instead in 2026 for the landscape and the host swap, then the field-by-field threshold guide to re-tune your numbers. This post assumes you can already get a track's key and BPM and want to build set logic on top.
The idea: score the transition, not just the track
Every competing API that survived the Spotify shutdown gives you raw key and BPM and stops there. The interesting part of DJing isn't the value of one track — it's the relationship between two adjacent tracks: are the keys harmonically compatible on the Camelot wheel, are the tempos close (or a clean half/double-time match), and is the energy moving the way you want the room to move?
FreqBlog exposes that relationship directly. GET /transition takes two catalog tracks and returns a 0–100 score with the breakdown:
GET /transition?from_track_id=apple_ad1829eeccb70f9a&to_track_id=1443810719
X-Api-Key: sk_live_...
{
"from_track": { "track_name": "Can't Stop Lovin' You", "camelot": "11B", "bpm": 117.8, ... },
"to_track": { "track_name": "You Beat Me to the Punch", "camelot": "11B", "bpm": 117.5, ... },
"score": 99,
"components": { "harmonic": 100, "tempo": 98, "energy": 100 },
"detail": {
"key_relation": "same",
"from_camelot": "11B", "to_camelot": "11B",
"from_bpm": 117.77, "to_bpm": 117.48,
"bpm_delta": -0.29, "bpm_octave_matched": false,
"from_energy": 0.69, "to_energy": 0.81, "energy_delta": 0.12
},
"reason": "11B->11B same key, 118->117 BPM (-0.29), energy +0.12"
}
The score blends three components: harmonic compatibility (Camelot-wheel relationship — same key, relative major/minor, adjacent ±1, energy boost/drop), tempo proximity (octave-aware, so a 70→140 BPM half-time blend counts as a match), and energy smoothness. The reason string is human copy you can drop straight into a UI.
Step 1 — resolve your crate to catalog IDs
The set-builder endpoints work on catalog itunes_track_id values (e.g. apple_ad1829eeccb70f9a). You get those from any /lookup or /search response — so a crate of plain track names becomes a list of IDs with one lookup each:
import requests
API = "https://api.freqblog.com"
HEADERS = {"X-Api-Key": "sk_live_your_key_here"}
def resolve(track, artist):
"""Track name + artist -> catalog itunes_track_id (or None on a miss)."""
r = requests.get(f"{API}/lookup",
params={"track": track, "artist": artist},
headers=HEADERS, timeout=30)
if r.status_code != 200:
return None # 202 = queued for ingest, try again shortly
return r.json().get("itunes_track_id")
crate = [
("Can't Stop Lovin'", "Tom Browne"),
("Got To Be Real", "Cheryl Lynn"),
("Ain't Nobody", "Chaka Khan"),
("Square Biz", "Teena Marie"),
("Forget Me Nots", "Patrice Rushen"),
]
ids = [tid for (t, a) in crate if (tid := resolve(t, a))]
Note: A
/lookupon a track that isn't analysed yet returns202and queues an on-demand ingest — it'll be ready in 30 s–2 min and resolve on a retry. For a planner, resolve your crate once and cache the IDs.
Step 2 — "what plays next?" with /next-track
Given any seed track, GET /next-track returns the seed's closest sonic neighbours re-ranked by transition score — each with the same score, components, and reason as /transition. This is your live "suggest the next tune" box:
def suggest_next(seed_id, n=5, min_score=85):
r = requests.get(f"{API}/next-track",
params={"seed_track_id": seed_id, "n": n,
"min_score": min_score,
"exclude_same_artist": "true"},
headers=HEADERS, timeout=30)
r.raise_for_status()
return r.json()["suggestions"]
for s in suggest_next(ids[0], n=5):
t = s["track"]
print(f"{s['score']:>3} {t['track_name']} - {t['artist_name']} ({s['reason']})")
For our seed — "Can't Stop Lovin'" at 11B / 118 BPM — the top picks come back same-key (11B) and within a beat or two of 118 BPM, scoring 98–99 with reasons like "11B->11B same key, 118->117 BPM (-0.29), energy +0.12". The bpm_drift and max_key_distance query params let you widen or tighten the net.
Step 3 — order the whole crate with /setlist
The payoff endpoint. POST /setlist takes your list of IDs and returns them ordered into an energy arc, keeping every consecutive transition harmonically and tempo-smooth. Pick the arc: peak_time (build to a peak, then ease), warmup, cooldown, or flat.
def build_set(ids, arc="peak_time", start_id=None):
body = {"track_ids": ids, "arc": arc}
if start_id:
body["start_track_id"] = start_id # optional fixed opener
r = requests.post(f"{API}/setlist", json=body, headers=HEADERS, timeout=30)
r.raise_for_status()
return r.json()
plan = build_set(ids, arc="peak_time")
print(f"Flow score: {plan['flow_score']}/100 ({plan['count']} tracks, arc={plan['arc']})")
for i, t in enumerate(plan["tracks"]):
print(f" {i+1:>2}. {t['track_name']} - {t['artist_name']}")
for step in plan["transitions"]:
print(f" {step['from_index']}->{step['to_index']} {step['score']:>3} {step['reason']}")
ordered_ids = [t["itunes_track_id"] for t in plan["tracks"]]
You get back the tracks in play order, a per-step transitions list (each with its own score and reason), an overall flow_score (the mean of the step scores — how smooth the whole set is), and an omitted list of any IDs not in the catalog. That's a complete, defensible running order — not a shuffle.
Step 4 — export to Rekordbox in one call
Take the ordered IDs from /setlist and hand them to GET /export/rekordbox to get a Pioneer-format XML you can import into Rekordbox or Serato:
def export_rekordbox(ordered_ids, path="my_set.xml"):
r = requests.get(f"{API}/export/rekordbox",
params={"track_ids": ",".join(ordered_ids)},
headers=HEADERS, timeout=30)
r.raise_for_status()
with open(path, "w", encoding="utf-8") as f:
f.write(r.text)
print(f"Wrote {path} - import via Rekordbox > File > Import Collection")
export_rekordbox(ordered_ids)
That's the whole planner: resolve → suggest → order → export. Counting the import, the helpers, and the crate, it lands right around 50 lines — and the part that used to be impossible without Spotify's data (or a pile of your own DSP) is now four HTTP calls.
What each call costs
The set-builder endpoints run through the same monthly-quota model as /lookup, and you're only charged on a served 200 — a 404 (track not in catalog) or 422 (bad/missing params) costs nothing.
| Endpoint | Quota | Why |
|---|---|---|
GET /transition |
1 request | A single A→B comparison, same as a /lookup. |
GET /next-track |
3 requests | Scores up to ~1,000 candidate neighbours to rank the top N. |
POST /setlist |
5 requests | An N² scoring problem — the premium "order my whole crate" call. |
So a typical session — resolve a 12-track crate (12), order it once (/setlist, 5), then export (free) — is well under 20 quota requests. The free tier's 1,000/month covers a lot of set-building before you need a paid plan.
One honest note on scope.
/setlistuses a greedy ordering with an energy-arc objective — it's built for clean, playable running orders, not a globally-optimal tour through your crate. For long sets, run it per-section (warmup / peak / cooldown crates) and stitch, or use/next-trackinteractively while you play. Theflow_scoretells you how smooth the result is so you can spot a weak link.
Why this works without Spotify
- No audio upload. Everything is keyed by track name, ISRC, or catalog ID against a pre-analysed catalog — you never ship a file.
-
No Spotify ID. The crate is plain names;
/lookupresolves them and queues an on-demand analysis for anything not yet in the catalog. - First-class harmonic data. Camelot and Open Key notation are returned directly, so the transition scoring has real harmonic relationships to work with — not just two raw key integers you have to compare yourself.
- The set logic is the product. Raw key/BPM is table stakes; the differentiator is scoring the transition and ordering the set. That's what these three endpoints sell.
Try FreqBlog — free tier, no card: https://freqblog.com/
Further reading
- Spotify Audio Features Is Dead. Here's What to Use Instead in 2026. — the landscape and the host-swap walkthrough
- Migrating from Spotify Audio Features: a Field-by-Field Threshold Guide — re-tune your numbers once the calls flow
- Camelot Wheel for Developers: Harmonic Mixing Without the Music Theory PhD — the harmonic rules behind the transition score
- Half-Time vs Double-Time BPM Detection — why the tempo score is octave-aware
- Why DJ Platforms Disagree on Key 60% of the Time
Originally published at freqblog.com.
Top comments (0)