Beta founding offer: lock in 50% off any plan for life, before the beta ends. Claim founding pricing →
ResourcesLibrary ManagementHandling Missing ISRCs: Fuzzy Search Fallback Patterns

Handling Missing ISRCs: Fuzzy Search Fallback Patterns

When ISRC lookup returns nothing, fall back to /v1/tracks/search. Query construction, popularity-weighted disambiguation, edit-distance scoring, and knowing when to give up.

When ISRC isn't there

ISRC lookup is the gold standard for matching tracks across platforms, but it's only as good as your source data. Indie releases via aggregators sometimes ship without one. DJ-uploaded mixes, podcasts, white-label promos, and user-uploaded local files often have nothing in the ISRC field at all.

For the rest of the library, the fallback is /v1/tracks/searchby artist + title. That introduces ambiguity: a search for “Strobe” matches the original, the radio edit, the club mix, and three remixes. So the job isn't just making the call; it's scoring the results to figure out which one (if any) is the real match.

Why this matters. A bad fuzzy match is worse than no match at all. If you accept the wrong canonical track ID, you merge two genuinely different recordings, losing analytics precision, breaking dedup, and corrupting downstream enrichment. Always score and threshold; never trust results[0] blindly.
Build
1

Start with the naive call

The basic shape: artist and title, take the top result:

TypeScriptnaive.ts
// The naive call - works for the easy cases.
const qs  = new URLSearchParams({ artist: "Daft Punk", title: "One More Time" });
const res = await fetch(`${BASE}/tracks/search?${qs}`, {
  headers: { "x-api-key": API_KEY },
});
const { results } = await res.json();
const track = results[0]; // might be right. might not.

This works for clean inputs and famous tracks. It fails on casing differences, “feat. X” vs “ft. X”, parenthetical mix variants, and any title that's a common word across many recordings.

2

Normalise both sides before comparing

Apply the same noise-stripping function to both your source string and the API response before scoring. This collapses casing, drops parentheticals (mostly mix annotations), strips “feat. X” suffixes, and removes punctuation:

TypeScriptnormalise.ts
// Strip noise before comparing. The same function is applied to both
// the source row and the API result before scoring.

function norm(s: string): string {
  return s
    .toLowerCase()
    .replace(/\(.*?\)|\[.*?\]/g, "")            // drop parentheticals
    .replace(/\b(feat|ft|featuring)\.?\s+.*$/i, "") // drop "feat. X"
    .replace(/[^a-z0-9\s]/g, "")                    // drop punctuation
    .replace(/\s+/g, " ")
    .trim();
}

console.log(norm("Strobe (Radio Edit) feat. Steve Duda"));
// "strobe"
3

Score every result and threshold

Pull limit=10 results and score each against the source row using normalised Damerau-Levenshtein similarity. Title carries more weight than artist (artist matches tend to be high-or-zero, title carries more signal). Reject anything below 0.85:

TypeScriptscored-lookup.ts
interface Track    { id: number; title: string; artists: { name: string }[]; isrc: string; }
interface SourceRow { id: string; artist: string; title: string; }

// Damerau-Levenshtein normalised by max length - 1.0 = perfect.
function sim(a: string, b: string): number {
  const dist = levenshtein(a, b);
  const max  = Math.max(a.length, b.length);
  return max === 0 ? 1 : 1 - dist / max;
}

function score(row: SourceRow, t: Track): number {
  const titleSim  = sim(norm(row.title),  norm(t.title));
  const primaryAr = t.artists[0]?.name ?? "";
  const artistSim = sim(norm(row.artist), norm(primaryAr));
  // Title carries more weight; artist matches are usually high-or-zero.
  return titleSim * 0.6 + artistSim * 0.4;
}

async function fallbackLookup(row: SourceRow, threshold = 0.85) {
  const qs  = new URLSearchParams({ artist: row.artist, title: row.title, limit: "10" });
  const res = await fetch(`${BASE}/tracks/search?${qs}`, {
    headers: { "x-api-key": API_KEY },
  });
  if (!res.ok) return null;
  const { results }: { results: Track[] } = await res.json();

  const ranked = results
    .map(t => ({ track: t, score: score(row, t) }))
    .sort((a, b) => b.score - a.score);

  const best = ranked[0];
  return best && best.score >= threshold ? best : null;
}
💡Don't threshold lower than 0.7 unless you're manually reviewing the matches. Below that you're injecting garbage into your library faster than you're recovering missing rows.
4

Retry with progressive simplification

When the first attempt misses, try simpler variants of the same query before giving up. Two or three attempts is enough; past that you're burning credits on diminishing recall:

TypeScriptretry.ts
async function fallbackWithRetries(row: SourceRow) {
  // Attempt 1: as-is
  let hit = await fallbackLookup(row);
  if (hit) return hit;

  // Attempt 2: strip parentheticals from the title
  hit = await fallbackLookup({
    ...row,
    title: row.title.replace(/\s*\(.*?\)|\s*\[.*?\]/g, "").trim(),
  });
  if (hit) return hit;

  // Attempt 3: drop a "feat. X" if the title contains one
  const sansFeat = row.title.replace(/\s+(feat|ft|featuring)\.?\s+.*$/i, "").trim();
  if (sansFeat !== row.title) {
    hit = await fallbackLookup({ ...row, title: sansFeat });
    if (hit) return hit;
  }

  return null;
}

Going further

  • Adopt the canonical ISRC.When a fallback match succeeds, take the matched track's ISRC and store it on your row. Next time you process this track, the ISRC path runs first and the expensive fuzzy search is skipped.
  • Flag unresolved rows for review.Rows that hit no confident match should go to a queue, not the trash. A human can often resolve in seconds what the algorithm can't.
  • Pair with cross-platform backfill. See cross-platform ID backfill for the bulk pipeline this falls back into once an ISRC has been recovered.

Frequently asked questions

Why do some tracks have no ISRC in the first place?

ISRCs are issued by labels. Indie releases on aggregators (DistroKid, TuneCore, etc.) sometimes ship without one. DJ-uploaded mixes, podcasts, and white-label promos almost never have them. Spotify itself sometimes drops the field on user-uploaded local files. For a typical commercial library, you'll see >95% ISRC coverage; for a DJ tool ingesting unofficial mixes, it can drop below 60%.

What's a sensible confidence threshold?

Start at 0.85 normalised edit distance: strict enough to avoid bad merges, loose enough to handle typos and “feat.” vs “ft.”. Tune per library: a curated commercial catalog can run at 0.9; a chaotic DJ library may need 0.75 with manual review of borderline matches.

What if the top result is wrong but the second result is right?

Walk the results array, not just results[0]. Score each one against your source row and pick the best; sometimes a remix appears above the original, or a live version above the studio cut. Order in the response is by SonoVault's match score, but your scoring may differ.

Should I retry with different queries if nothing matches?

Yes, but cap it. Useful retries: strip parenthetical suffixes (“(Original Mix)”, “(Radio Edit)”), drop “feat. X” from the title, swap artist and title if your data is inconsistent. Three attempts max; past that the recall is too low to be worth the credits.

Ready to build?

Free API key. No credit card. 1,000 requests to get started.

Get Free API Key
More in Library Management
Library ManagementBackfilling Cross-Platform Track IDs for a Spotify-Only Library7 min readLibrary ManagementNormalising Genres Across Spotify, Beatport, and Discogs6 min read