Why dump a full discography
A label's back-catalogue is the most concentrated unit of taste in music metadata. For an A&R team, a sample-clearance manager, a record-store owner doing inventory, or anyone building a label-focused feature, having every release on the label sitting locally — in JSON, in a SQL table, in a spreadsheet — opens up things you can't do through the live API alone. Cross-label comparisons, decade-by-decade breakdowns, average-tracks-per-release stats, the whole long-tail of analytics.
The walk itself is just cursor pagination over /v1/labels/:id/releases. The interesting part is what comes next: enriching each release with its tracks via /v1/releases/:id, and doing the whole thing incrementally so a daily run only fetches what's actually new.
Find the label and check the count
Get the label ID from /v1/labels/search and verify release_count matches what you expect — sanity check before you start pulling pages:
{
"id": 1,
"name": "Drumcode",
"release_count": 842,
"artist_count": 67
}Page through every release
/v1/labels/:id/releases returns releases ordered by date (newest first), 100 per page, cursor-paginated:
{
"results": [
{
"id": 9102,
"title": "Drumcode 01",
"artist": { "id": 5, "name": "Adam Beyer" },
"label": { "id": 1, "name": "Drumcode" },
"release_date": "2024-03-15",
"catalog_no": "DC001",
"track_count": 8
},
/* … */
],
"next_cursor": "2024-03-15:9102"
}Walk it with the basic cursor loop — accumulate, stop when next_cursor is null:
import fs from "node:fs"; const API_KEY = process.env.SONOVAULT_API_KEY!; const BASE = "https://api.sonovault.now/v1"; interface Release { id: number; title: string; artist: { id: number; name: string }; label: { id: number; name: string } | null; release_date: string; catalog_no: string | null; track_count: number; } async function walkLabel(labelId: number): Promise<Release[]> { const out: Release[] = []; let cursor: string | null = null; while (true) { const qs: Record<string, string> = { limit: "100" }; if (cursor) qs.cursor = cursor; const u = `${BASE}/labels/${labelId}/releases?${new URLSearchParams(qs)}`; const res = await fetch(u, { headers: { "x-api-key": API_KEY } }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); const page = await res.json(); out.push(...page.results); console.log(` fetched ${out.length} so far…`); if (!page.next_cursor) break; cursor = page.next_cursor; } return out; } const labelId = 1; const releases = await walkLabel(labelId); console.log(`Walked ${releases.length} releases`); fs.writeFileSync(`./label-${labelId}.json`, JSON.stringify(releases, null, 2));
limit=100is the max — anything higher is clamped server-side. For a 5,000-release label that's 50 requests; at default 1 RPS rate limits you're done in under a minute.Enrich each release with its tracks
/v1/labels/:id/releases returns release metadata, not tracks. For the full track list — title, ISRC, duration, genre, artists — fetch /v1/releases/:id per release. Modest concurrency (4 in flight) keeps you well under rate limits without dragging the walk out:
interface ReleaseFull extends Release { tracks: { id: number; title: string; isrc: string; duration: number; genre: string[]; subgenre: string[] }[]; } async function withTracks(release: Release): Promise<ReleaseFull> { const res = await fetch(`${BASE}/releases/${release.id}`, { headers: { "x-api-key": API_KEY }, }); const full = await res.json(); return { ...release, tracks: full.tracks }; } // Modest concurrency — 4 in flight at a time. // (For real production use, swap for p-limit or a proper queue.) async function enrichAll(releases: Release[]): Promise<ReleaseFull[]> { const out: ReleaseFull[] = []; const CONCURRENCY = 4; for (let i = 0; i < releases.length; i += CONCURRENCY) { const batch = await Promise.all( releases.slice(i, i + CONCURRENCY).map(withTracks), ); out.push(...batch); } return out; } const full = await enrichAll(releases); fs.writeFileSync(`./label-${labelId}-full.json`, JSON.stringify(full, null, 2));
For a 5,000-release label, that's ~15 minutes end-to-end. Run it once, persist to disk, then update incrementally from there.
Incremental walks for daily updates
On subsequent runs, walk only until you hit a release you already have — releases are date-ordered, so anything newer is at the top. Store the highest release_date seen and stop once you reach it:
import fs from "node:fs/promises"; async function incrementalWalk(labelId: number, sinceFile: string): Promise<Release[]> { let since = ""; try { since = (await fs.readFile(sinceFile, "utf-8")).trim(); } catch { /* first run; full walk */ } const out: Release[] = []; let cursor: string | null = null; let newest: string = since; walking: while (true) { const qs: Record<string, string> = { limit: "100" }; if (cursor) qs.cursor = cursor; const u = `${BASE}/labels/${labelId}/releases?${new URLSearchParams(qs)}`; const page = await (await fetch(u, { headers: { "x-api-key": API_KEY } })).json(); for (const r of page.results as Release[]) { if (since && r.release_date <= since) break walking; // caught up out.push(r); if (r.release_date > newest) newest = r.release_date; } if (!page.next_cursor) break; cursor = page.next_cursor; } if (newest) await fs.writeFile(sinceFile, newest); return out; }
For a label that puts out 1–2 releases per week, the daily walk fetches one page (sometimes none) and is done in a second. Schedule a full re-walk weekly to catch backdated edits, re-imports, and removed releases.
Going further
- Add cross-platform IDs per release. Pair each enriched track with
/v1/tracks/linksfor Spotify, Apple Music, Tidal, Beatport, Discogs, and MusicBrainz IDs. See cross-platform ID backfill for the batching pattern. - Walk the artist roster too.
/v1/labels/:id/artistsreturns every artist with at least one release on the label, ordered by their release count on that label. Stitch with the discography for an artist-centric view. - Periodic deltas as a webhook. Run the incremental walk hourly, post anything new to Slack via the same pattern as the competitor label alerts article.
Frequently asked questions
How long does a full walk take for a 5,000-release label?
About 100 seconds at limit=100with one request every ~2 seconds (no concurrency needed). For a 5,000-release label, that's 50 release-list calls. If you want every track too, add ~5,000 release-detail calls — closer to 15 minutes. Cache the result; do the full walk weekly, fetch incrementals daily.
Why not just use /v1/tracks/browse with labelId?
/v1/tracks/browsereturns at most 20 tracks per call and doesn't paginate — it's a discovery endpoint. For complete enumeration of a label, walk /v1/labels/:id/releases, then optionally fetch /v1/releases/:id per release to get tracks.
Will a label's release_count match the number of rows I get?
Yes — release_counton the label endpoint is the same count you'll page through. Mismatch usually means you're computing it from a deduplicated set (e.g. unique titles), or that releases were added/removed between the two calls.
Can I incrementally update an existing local copy?
Yes. Store the highest release_date seen so far. On the next run, walk until you hit that date and stop — new releases are at the top. Cap at one full re-walk per week to catch back-dated edits or re-imports.