What the pipeline does
Every radio automation platform can hand you a play log — RadioBoss, SAM Broadcaster, AzuraCast, StationPlaylist, RadioDJ all export artist, title, album, timestamp, and a play count. What none of them reliably include is the ISRC and record label— the two fields that make a line compliant on a SoundExchange Report of Use. This is the recipe that closes that gap in one pass.
Five stages, each a plain transform over the previous one: read the export → dedupeto distinct tracks → resolve each one (and cache it) → aggregate the play counts → emit the delimited file. The only network step is resolve, and one lookup returns everything the log is missing:
{
"results": [
{
"id": "315587019",
"title": "One More Time",
"isrc": "GBDUW0000059", // the recording that aired
"duration": 320,
"releases": [
{ "title": "Discovery", "label": { "name": "Virgin" } }
]
/* … artists, genre … */
}
],
"next_cursor": null
}isrc, releases[].title, releases[].label.name), so whichever path a given row files under, you already have it.Read the export
Parse your automation platform's log into rows, keeping artist, title, album, and the timestamp. The exact CSV shape varies by product, but the rest of the pipeline only assumes those four columns.
Dedupe to distinct tracks
A log has one row per spin, so the same recording shows up dozens of times. Collapse to unique artist + titlebefore you touch the network — resolve each recording once, not once per play:
// A play log has one row per spin. Resolve each DISTINCT track once — // a track aired 40 times should cost one lookup, not forty. type Row = { artist: string; title: string; album?: string; playedAt: string }; const keyOf = (r: Row) => (r.artist + " | " + r.title).toLowerCase().trim(); const rows: Row[] = parseExport(raw); // your CSV / log parser const unique = new Map<string, Row>(); for (const r of rows) unique.set(keyOf(r), r); console.log(unique.size + " distinct tracks from " + rows.length + " spins");
SonoVault's search already normalises cosmetic noise (“(Radio Edit)”, feat. credits, inconsistent casing), but a local key still saves you the duplicate calls.
Resolve each unique track
Call GET /v1/tracks/searchwith the artist and title (both are required — there is no free-text query), take the top result, and read off the ISRC, album, and label. Cache the result and respect Retry-After on a 429:
// Resolve one distinct track to the two fields the log is missing. // Both artist AND title are required — there is no free-text query. type Enriched = { isrc: string | null; album: string | null; label: string | null }; const cache = new Map<string, Enriched>(); async function resolve(r: Row): Promise<Enriched> { const u = new URL(BASE + "/v1/tracks/search"); u.searchParams.set("artist", r.artist); u.searchParams.set("title", r.title); u.searchParams.set("limit", "1"); const res = await fetch(u, { headers: { "x-api-key": API_KEY } }); if (res.status === 429) { // rate limited — respect Retry-After const wait = Number(res.headers.get("retry-after") || 2); await sleep(wait * 1000); return resolve(r); // retry after backoff } const { results } = await res.json(); const top = results[0]; if (!top) return { isrc: null, album: null, label: null }; const rel = top.releases[0]; // representative release return { isrc: top.isrc, // may be null album: rel ? rel.title : null, label: rel && rel.label ? rel.label.name : null, }; }
Aggregate play counts
Now fan back out. Count spins per distinct track over the reporting period, then join the enriched identifiers onto those counts:
// Sum spins per distinct track, then join on the enriched identifiers. // Census reporting = actual total performances over the period. const counts = new Map<string, number>(); for (const r of rows) { const k = keyOf(r); counts.set(k, (counts.get(k) || 0) + 1); } const reportRows = [...unique].map(([k, row]) => { const e = cache.get(k)!; // resolved earlier return { title: row.title, artist: row.artist, isrc: e.isrc, // ISRC path… album: e.album, // …or album + label label: e.label, // as the fallback performances: counts.get(k) || 0, }; });
Census reporting wants actual total performances— the real spin count — not a sample, unless you specifically qualify as a minimum-fee simulcaster.
Emit the report
Write reportRows out as the delimited text file SoundExchange expects and file it through Licensee Direct. Validate the layout against the current templatefrom the portal rather than hardcoding column names from a blog post — the field set is stable, but the exact header strings are SoundExchange's to define. SonoVault's job ends at supplying accurate ISRC, album, and label values; filing specifics and thresholds are SoundExchange's.
Handling the misses
- No match at all.A row that resolves to nothing goes in a manual-review bucket — never dropped. Odd spellings and very obscure tracks are the usual culprits.
- No ISRC, but a release. When
isrccomes backnull, you still havealbumandlabel— file that row under the album + label fallback. See ISRC vs. Album + Label. - Several ISRCs for one song. A recording can carry many ISRCs (radio edit, extended mix, remaster). Report the one that actually aired; resolving from the version in your own library keeps that honest.
Caching across months
Persist the artist + title → { isrc, album, label }cache between runs — a file, a small table, a key-value store, whatever you already have. Next month's report then only resolves the genuinely new tracks in rotation, which for most stations is a handful. Your catalogue stops being a monthly bill and becomes a slowly-growing local index.
That is the whole pipeline: the automation log gives you what aired and how often; SonoVault fills in the identifiers it never had; and the report writes itself from the join. For the click-through version of the same idea, the Report of Use overview lays out the fields and cadence, and the RadioBoss guide does step 3 with no code.
Frequently asked questions
Why dedupe before resolving anything?
A play log has one row per spin, so a track aired forty times appears forty times. Resolving each row would burn forty lookups (and forty times the rate-limit budget) for one distinct recording. Collapsing to unique artist + title first means you resolve each track exactly once, then fan the result back out over its spins when you aggregate.
Should I use the API or the Bulk Lookup tool?
Both hit the same data. The Bulk Lookup web tool is the no-code path: paste up to 1,000 track names, pick the ISRC and Label columns, download the enriched CSV. The search API is for a repeatable monthly pipeline where you want dedupe, caching, and aggregation wired together. Small station, once a month: the tool. Automated reporting: the API.
What do I do with a track that has no ISRC?
Fall back to album + label — SoundExchange accepts either the ISRC or the album name and marketing label together, and SonoVault returns the album (releases[].title) and label (releases[].label.name) even when the representative isrc is null. Keep any row that resolves to neither in a manual-review bucket rather than dropping it.
Does SonoVault build and file the report for me?
No. SonoVault supplies the two fields your automation log is missing — ISRC and record label (plus album). You assemble the delimited file and file it through SoundExchange Licensee Direct. Validate the layout against the current template rather than the column names in this article.