SonoVault is in open beta — signups are live. Get your free API key →
ResourcesLibrary ManagementWalking a Label's Full Discography Programmatically

Walking a Label's Full Discography Programmatically

Page through every release on a label with /v1/labels/:id/releases and stitch a complete back-catalogue export — tracks, artists, ISRCs, the whole thing. Works for 5-release indies and 5,000-release majors.

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.

Cost. A 500-release label costs ~5 list calls to enumerate + 500 detail calls if you want tracks. A 5,000-release major label costs ~50 + 5,000 detail calls. All cheap; the detail calls dominate. Cache the result; do the full walk weekly, do incremental walks daily.
Build
1

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:

GET/v1/labels/1200 OK
{
  "id":            1,
  "name":          "Drumcode",
  "release_count": 842,
  "artist_count":  67
}
2

Page through every release

/v1/labels/:id/releases returns releases ordered by date (newest first), 100 per page, cursor-paginated:

GET/v1/labels/1/releases?limit=100200 OK
{
  "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:

TypeScriptwalk.ts
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.
3

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:

TypeScriptenrich.ts
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.

4

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:

TypeScriptincremental.ts
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/links for 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/artists returns 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.

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