Back to blog
Appears On: Reverse Playlist Lookup for Spotify
Web Development

Appears On: Reverse Playlist Lookup for Spotify

Spotify has no 'playlists containing this song' endpoint, so I built one — a keyword net, ISRC matching, and a streaming scan that surfaces playlists ranked by likes.

You know the feeling: a song comes on with exactly the right vibe, and you want more of that. Not more of the artist, more of the mood. The best source for that already exists — thousands of strangers have hand-built playlists around that exact song. The problem is finding them.

Spotify has a "Discovered On" section for artists, but nothing like it for songs. So I built Appears On: give it one track and it finds the public playlists that include it, ranked by likes. The most-loved playlists containing your song are usually the best vibe-matches you'll find anywhere.

The Problem: The Endpoint Doesn't Exist

The obvious implementation would be one API call: "give me playlists containing track X." Spotify's Web API has no such endpoint. You can search playlists by keyword, and you can read a playlist's contents, but there's no reverse index from track to playlist.

So the app fakes one, in three phases:

  1. Cast a keyword net — search playlists using several queries built from the track
  2. Verify candidates — fetch each playlist and scan its actual contents for the song
  3. Stream results — push every confirmed playlist to the browser the moment it's verified

Casting the Net

A single search query isn't enough. Searching the quoted song title mostly surfaces playlists named after the song, while artist and album queries surface normally-named playlists that mention them in a description. So the scan runs several queries and pages through them round-robin, so no single query hogs the candidate budget:

const queries = [
  ...new Set(
    [`"${title}"`, `${title} ${artist}`, title, artist,
     album !== title ? album : ""].filter(Boolean)
  ),
];

Then comes the fun part: throwing candidates away. A playlist literally named after the song is almost always a karaoke-style duplicate, and one named after the artist is a tribute list — neither is someone's personal vibe collection, which is what we're actually after. So the scan bans the song title and any two consecutive words of the artist name from playlist names.

People also love stylized unicode in playlist names (𝒱𝒾𝒷𝑒𝓈 ✨), so name checks first fold everything down to plain lowercase:

function fold(s: string): string {
  return s
    .normalize("NFKD")
    .replace(/\p{M}/gu, "")
    .toLowerCase()
    .replace(/\s+/g, " ")
    .trim();
}

Without this, a playlist could dodge the filter (or fail a match) just by spelling its name in fancy script letters.

Verifying with ISRC

A keyword match only means the playlist might contain the song. The scan fetches each candidate's tracklist and looks for the actual track — but matching by track ID alone would miss a lot, because the same recording often exists under multiple IDs: the album version, the single, the remaster, the deluxe reissue.

The fix is the ISRC — the International Standard Recording Code, a universal identifier for a specific recording that stays stable across re-releases. A playlist counts as a match if any item matches by track ID or ISRC:

const matches = (item) =>
  item?.track &&
  (item.track.id === trackId ||
    (isrc && item.track.external_ids?.isrc === isrc));

This one check meaningfully improves recall — playlists built from the 2015 remaster still count when you searched for the original pressing.

Streaming Results as They're Found

Scanning up to a hundred playlists takes a while, and nobody wants to stare at a spinner for thirty seconds before seeing anything. The whole pipeline is written as an async generator, so the route handler can forward each event the instant it happens:

export async function* findPlaylists(/* ... */) {
  yield { type: "status", phase: "searching" };
  const candidates = await searchPlaylistCandidates(title, artist, album);
  yield { type: "status", phase: "scanning", candidates: candidates.length };

  // verify candidates in parallel, yielding results as each finishes
  while (inFlight.size > 0) {
    const { id, v } = await Promise.race(inFlight.values());
    inFlight.delete(id);
    if (queue.length > 0) launch(queue.shift()!);
    if (v?.playlist) yield { type: "result", playlist: v.playlist };
    yield { type: "progress", scanned, matched };
  }
}

Verification runs six playlists at a time in a small worker pool — Promise.race picks off whichever fetch finishes first, and a new candidate launches immediately in its place. The route handler wraps the generator in a ReadableStream and sends each event as a line of NDJSON:

const send = (event: unknown) =>
  controller.enqueue(encoder.encode(JSON.stringify(event) + "\n"));

for await (const event of findPlaylists(trackId, title, artist, album, isrc)) {
  send(event);
}

On the client, results pop in one by one with a live progress count, sorted by likes as they arrive. The first confirmed playlist usually shows up within a couple of seconds, which feels completely different from waiting on a batch response — even though the total scan time is identical.

The Development Mode Plot Twist

Midway through this project I ran headfirst into a Spotify platform change. As of February 2026, new API keys in Development Mode can no longer read the contents of playlists their user doesn't own. Which is, unfortunately, the entire premise of this app.

Rather than pretending the problem doesn't exist, the app degrades honestly. When Spotify withholds a playlist's items, that playlist is shown as ○ unverified — a keyword match the app couldn't confirm — with an explanation in the UI. Older keys (or ones with extended quota access) can read everything and show ● contains it. Nothing is configured; the code detects what the key can see per playlist:

let items = data.tracks?.items;
if (items === undefined && base.totalTracks > 0) {
  // Development Mode: contents withheld for playlists we don't own.
  return { playlist: { ...base, status: "unverified" }, restricted: true };
}

Development Mode also caps search at 10 results per request, so the scan paginates in steps of 10 and works within explicit budgets — max candidates, max tracks scanned per playlist, concurrency — tuned to keep a full scan comfortably inside Vercel's 60-second function window.

Keeping It Small

The whole app is deliberately tiny: Next.js and Tailwind, one lib file for the Spotify logic, one client component for the finder UI, three API routes. There's no database, no user login — the server uses Spotify's client-credentials flow for public catalog data only, with an in-memory token cache and 429 backoff.

Sometimes the most satisfying projects are the ones that fill a small, specific gap: Spotify almost has this feature. Now it does. Try it at appears-on.vercel.app — feed it the song you can't stop playing and see whose playlists it lives in.

Moebius
Sam Fortin