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.
results[0] blindly.Start with the naive call
The basic shape: artist and title, take the top result:
// 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.
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:
// 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"
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:
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; }
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:
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.