DEV Community

Cover image for The Spotify /recommendations Replacement
Freqblog
Freqblog

Posted on • Originally published at freqblog.com

The Spotify /recommendations Replacement

When people talk about the Spotify Web API deprecations, they usually mean audio_features and audio_analysis — the per-track numbers. But the same November 2024 cull also took GET /v1/recommendations and GET /v1/artists/{id}/related-artists, and those two have arguably left the bigger hole. Eighteen months on, "Spotify recommendations endpoint deprecated" is still one of the most-asked questions on Stack Overflow and the developer forum, with no official replacement — for the seed-based recommender that powered a huge number of "discover" features and "fans also like" pages.

This post is a practical walkthrough of building both back, in about 40 lines of Python, against the FreqBlog Music API: GET /recommendations (seed tracks → similar tracks) and GET /related-artists (an artist → nearby artists). No Spotify account, no deprecated endpoint, no audio files — you pass catalog track ids and artist names.

What was removed, exactly. Spotify deprecated /v1/recommendations and /v1/artists/{id}/related-artists for apps created after 27 November 2024, and the broader audio-intelligence set (audio_features, audio_analysis, audio previews) with them. If you also need the raw per-track numbers, start with Spotify Audio Features Is Dead. Here's What to Use Instead in 2026 for the host swap, then come back here for the recommender layer.

Why there's no drop-in clone — and what to build instead

Spotify's recommender was a black box backed by its full collaborative-filtering graph — billions of listening sessions. Nobody outside Spotify has that signal, so any honest replacement is built differently. The approach that actually ships is content-based similarity: describe each track by its audio features (tempo, key, energy, danceability, valence, acousticness…), and find neighbours in that feature space. It doesn't know that two tracks share an audience; it knows they sound alike. For "here's a track, give me more like it" that turns out to be most of what you want — and it has a property the collaborative graph never did: it works on brand-new and long-tail tracks with zero listening history.

FreqBlog runs that similarity index over its own pre-analysed catalogue and exposes it as two Spotify-shaped endpoints. The one twist worth knowing up front: pure audio-feature similarity is genre-blind — an EDM track and a hard-rock track can sit close in feature space if their tempo and energy line up. So the ranking is genre-aware: it uses FreqBlog's own catalogue genre data (around 94% coverage) to lift same-genre matches and push down a coincidentally-close cross-genre track. That's the bit that makes the results feel right rather than merely numerically near.

/recommendations — seed tracks in, similar tracks out

GET /recommendations is the direct stand-in for /v1/recommendations. Give it 1–5 catalog seed tracks; it blends them into a single point in audio-feature space (a centroid) and returns the nearest catalogue tracks, re-ranked by genre affinity. Like Spotify's endpoint, you pass seed tracks — the difference is the id format: FreqBlog itunes_track_id values (e.g. apple_ad1829eeccb70f9a), which you get from /search or any /lookup response, not Spotify URIs.

import requests

API = "https://api.freqblog.com"
HEADERS = {"X-Api-Key": "sk_live_your_key_here"}

def recommend(seed_ids, limit=10, exclude_seed_artists=False):
    r = requests.get(f"{API}/recommendations",
                     params={"seed_tracks": ",".join(seed_ids),
                             "limit": limit,
                             "exclude_seed_artists": str(exclude_seed_artists).lower()},
                     headers=HEADERS, timeout=30)
    r.raise_for_status()
    return r.json()

# Seed: "Jump" by Van Halen (hard rock). Catalog id from /search or /lookup.
data = recommend(["apple_ad1829eeccb70f9a"], limit=8)

for s in data["seeds"]:
    print(f"seed {s['id']}  found={s['found']}")
for rec in data["tracks"]:
    t = rec["track"]
    print(f"  {rec['score']:.3f}  {t['track_name']} - {t['artist_name']}  [{t.get('genre')}]")
Enter fullscreen mode Exit fullscreen mode

The response echoes your seeds with a found flag (so you can tell which ids resolved), a count, and a tracks list of {track, score} objects. For the Van Halen seed, the picks come back all hard rock — Alice Cooper, Guns N' Roses, Aerosmith, David Lee Roth — scoring around 0.93–0.95. Each track is a full stub: itunes_track_id, track_name, artist_name, album_name, genre, release_date, isrc/mbid when known. Feed any of those ids straight into /lookup for the full audio features.

One thing that trips people up: the list is NOT strictly score-descending. score is the raw audio-feature cosine similarity (0–1); the genre lift affects ordering, not the printed number. So a same-genre track with a slightly lower cosine can — correctly — rank above a cross-genre track with a higher cosine. If you re-sort the list purely by score on the client you'll throw away the genre-aware ranking. Trust the order it comes back in.

Blending multiple seeds

Multiple seeds work exactly like Spotify's: pass up to five and you get recommendations for the blend, not for each one in turn. This is how you build a "based on your recent likes" row — take the last few tracks a user engaged with, blend them, and the centroid lands in the middle of their taste:

recent_likes = [
    "apple_ad1829eeccb70f9a",   # hard rock
    "dz_92720102",              # arena rock (AC/DC)
    "1443810719",               # classic rock
]
data = recommend(recent_likes, limit=20, exclude_seed_artists=True)
print(f"{data['count']} recommendations for the blend")
Enter fullscreen mode Exit fullscreen mode

exclude_seed_artists=true drops anything by the seed artists themselves — the difference between "more of the same artist" and genuine discovery. Leave it off for a "more like this" box; turn it on for a "you might also like" row.

/related-artists — deriving an artist graph without one

GET /related-artists stands in for /v1/artists/{id}/related-artists — the endpoint behind every "fans also like" / "similar artists" panel. Spotify had an explicit artist-relationship graph to read from. FreqBlog has no such graph, so it derives one on the fly: it builds the seed artist's audio-feature centroid from their catalogue tracks, finds the nearest tracks across the catalogue, and aggregates those by artist.

def related(artist, limit=10):
    r = requests.get(f"{API}/related-artists",
                     params={"artist": artist, "limit": limit},
                     headers=HEADERS, timeout=30)
    r.raise_for_status()
    return r.json()

data = related("Van Halen", limit=8)
for a in data["related"]:
    print(f"  {a['score']:.2f}  {a['artist_name']}  "
          f"({a['match_count']} matching tracks, sample {a['sample_track_id']})")
Enter fullscreen mode Exit fullscreen mode

For Van Halen this leads with Danger Danger, AC/DC and Def Leppard — the right neighbourhood. Each result carries artist_name, a score, a match_count (how many of that artist's tracks landed in the seed's neighbourhood), and a sample_track_id — a representative catalog id you can hand straight to /lookup or to /next-track.

Two design choices keep the list sensible, and they're worth understanding because they explain results you might otherwise find surprising:

  • A per-artist cap. An artist's score sums only its top few track similarities, so a prolific catalogue artist with hundreds of near-tracks can't crowd out a tighter, more relevant match. Relevance, not catalogue size, wins.
  • A genre lift and a cross-genre penalty. Same as /recommendations: a candidate sharing the seed artist's dominant genre gets a lift; a candidate in a clearly different genre gets a penalty, which drops stragglers that are feature-close by coincidence.

Putting it together: a "discover" feed in ~40 lines

The two endpoints compose into the classic discovery surface — recommended tracks plus the artists behind them — with no extra plumbing:

def discover(seed_track_ids, seed_artist):
    recs = recommend(seed_track_ids, limit=10, exclude_seed_artists=True)
    arts = related(seed_artist, limit=6)

    print("Because you like that sound:")
    for rec in recs["tracks"]:
        t = rec["track"]
        print(f"  {t['track_name']} - {t['artist_name']}")

    print("\nFans also like:")
    for a in arts["related"]:
        print(f"  {a['artist_name']}")

discover(["apple_ad1829eeccb70f9a"], "Van Halen")
Enter fullscreen mode Exit fullscreen mode

That's the whole thing. The seed ids come from one /search or /lookup per track name; everything else is two HTTP calls. The part that used to require Spotify's recommendation graph is now a content-based query you own.

What each call costs

Both endpoints run through the same monthly-quota model as /lookup, and you're only charged on a served 200 — a 404 (no seed in the catalog) or 422 (no/invalid seed supplied) costs nothing.

Endpoint Quota Why
GET /recommendations 2 requests Blends up to 5 seeds into a centroid and scores the catalogue, then genre-re-ranks the pool.
GET /related-artists 2 requests Builds the seed artist's centroid, scores the catalogue, and aggregates by artist with the per-artist cap + genre lift.

So a discover feed — one /recommendations (2) plus one /related-artists (2) — is 4 quota units a render. The free tier's 1,000 requests/month is plenty to prototype with before you need a paid plan.

How it differs from Spotify's endpoint — honestly

  • Content-based, not collaborative. It finds tracks that sound similar, not tracks that share an audience. For "more like this" that's usually what you want, and it works on brand-new tracks with no listening history — where a collaborative recommender returns nothing.
  • Genre-aware ranking. Raw audio-feature similarity is genre-blind; FreqBlog re-ranks with its own ~94%-coverage catalogue genre so a feature-close cross-genre track doesn't outrank same-genre picks. This is FreqBlog's own open data — not ListenBrainz or any external dependency, so there's no extra latency or third-party rate limit in the path.
  • No Spotify ID, no Spotify account. Seeds are catalog ids resolved from track names; the artist graph is derived from the catalogue. Nothing in the chain touches a deprecated Spotify endpoint.
  • Coverage, not magic. Similarity is computed over FreqBlog's pre-analysed catalogue, so a seed track has to be in it (or get pulled in via on-demand backfill on a prior /lookup). A seed that isn't analysed yet comes back in the seeds echo with found=false; resolve and cache your seeds once, the same way you would seed ids for any catalog API.

Try FreqBlog — free tier, no card: https://freqblog.com/

Further reading


Originally published at freqblog.com.

Top comments (0)