SonoVault is in open beta — signups are live. Get your free API key →
ResourcesIntegration GuidesMigrate a User Library Between Streaming Platforms

Migrate a User Library Between Streaming Platforms

Take a Spotify library export and turn it into a ready-to-import Tidal playlist (or Apple Music, or Beatport collection). One bulk resolve call per 100 tracks, plus a per-platform writer.

When a user switches platforms

Switching from Spotify to Tidal — or any other DSP-to-DSP move — is easy to want and hard to do. The user has years of saved tracks, liked albums, and custom playlists, all keyed by Spotify track IDs. The destination platform has no idea what any of those IDs mean. Walking the library and re-adding each track manually is a project no one finishes.

The mechanical part — turning Spotify IDs into the equivalent Tidal IDs — is one bulk API call per 100 tracks. The hard part is honest coverage reporting: not every Spotify track exists on Tidal, and users want to know which ones won't come along before they commit to the move.

What this article covers. Source-platform ID list → destination-platform ID list, with a per-track outcome (matched, missing on target, unresolved). The actual write to the destination platform uses its own API (Tidal SDK, Apple MusicKit, etc.) — out of scope here, but the IDs are what those SDKs need.
Build
1

Resolve a batch from one platform to all others

POST /v1/tracks/resolve takes an input_type (the source platform) and a list of up to 100 IDs. The response gives you the canonical SonoVault track plus its links on every other platform we know about:

POST/v1/tracks/resolve(request body)
{
  "input_type": "spotify_id",
  "items": [
    "5W3cjX2J3tjhG8zb6u0qHn",
    "4uLU6hMCjMI75M1A2tKUQC",
    /* up to 100 */
  ]
}
POST/v1/tracks/resolve200 OK
{
  "results": [
    {
      "input":  "5W3cjX2J3tjhG8zb6u0qHn",
      "status": "matched",
      "track":  { "id": 123, "title": "One More Time", /* … */ },
      "links": [
        { "source": "tidal",      "external_id": "3789025", "url": "https://tidal.com/browse/track/3789025" },
        { "source": "applemusic", "external_id": "1440650", "url": "https://music.apple.com/song/1440650" },
        /* … */
      ]
    },
    /* … */
  ],
  "partial":           false,
  "processed":         2,
  "credits_used":      2,
  "credits_remaining": 9998,
  "message":           null
}
💡Order is preserved — results[j] corresponds to items[j]. That's what makes a positional join with your input list safe.
2

Walk the user's library in 100-track batches

For each input row, extract the destination platform's ID from links and bucket the row as matched, missing on target, or unresolved. The whole thing stays under 70 lines:

TypeScriptmigrate.ts
import fs from "node:fs";

const API_KEY = process.env.SONOVAULT_API_KEY!;
const BASE    = "https://api.sonovault.now/v1";
const BATCH   = 100;

type Platform = "spotify" | "applemusic" | "tidal" | "beatport" | "discogs" | "musicbrainz";

interface Migration {
  source: Platform;        // where the IDs came from
  target: Platform;        // where you're going
  inputIds: string[];
}

interface Outcome {
  sourceId: string;
  targetId: string | null;
  status:   "matched" | "missing_on_target" | "unresolved";
  title?:   string;
}

async function migrate({ source, target, inputIds }: Migration): Promise<Outcome[]> {
  const outcomes: Outcome[] = [];

  for (let i = 0; i < inputIds.length; i += BATCH) {
    const chunk = inputIds.slice(i, i + BATCH);

    const res  = await fetch(`${BASE}/tracks/resolve`, {
      method:  "POST",
      headers: { "x-api-key": API_KEY, "content-type": "application/json" },
      body:    JSON.stringify({
        input_type: `${source}_id`,
        items:      chunk,
      }),
    });
    const data = await res.json();

    for (let j = 0; j < chunk.length; j++) {
      const sourceId = chunk[j];
      const result   = data.results[j];

      if (result.status !== "matched") {
        outcomes.push({ sourceId, targetId: null, status: "unresolved" });
        continue;
      }

      const targetLink = result.links.find((l: { source: string }) => l.source === target);
      outcomes.push({
        sourceId,
        targetId: targetLink?.external_id ?? null,
        status:   targetLink ? "matched" : "missing_on_target",
        title:    result.track?.title,
      });
    }
    console.log(`Resolved ${Math.min(i + BATCH, inputIds.length)} / ${inputIds.length}`);
  }

  return outcomes;
}

// Example: Spotify → Tidal
const spotifyIds = fs.readFileSync("./spotify-library.txt", "utf-8").trim().split("\n");
const outcomes   = await migrate({ source: "spotify", target: "tidal", inputIds: spotifyIds });

const matched     = outcomes.filter(o => o.status === "matched");
const missing     = outcomes.filter(o => o.status === "missing_on_target");
const unresolved  = outcomes.filter(o => o.status === "unresolved");

console.log(`Matched: ${matched.length}`);
console.log(`Missing on Tidal: ${missing.length}`);
console.log(`Unresolved: ${unresolved.length}`);

fs.writeFileSync("./tidal-ids.txt", matched.map(o => o.targetId).join("\n"));
fs.writeFileSync("./missing-on-tidal.json", JSON.stringify(missing, null, 2));

For a 5,000-track Spotify library, that's 50 batches and ~30 seconds. Output goes to two files: the destination IDs ready to feed to the destination API, and the “missing” list for the user-facing report.

3

Show the user what will and won't come along

Before initiating the destination write, show a summary. Users tolerate “3% of your library isn't on Tidal” far better than discovering missing tracks after the fact:

TypeScriptreport.ts
// A user-facing summary you can show before initiating the destination write.

function renderReport(outcomes: Outcome[], target: Platform) {
  const total      = outcomes.length;
  const matched    = outcomes.filter(o => o.status === "matched").length;
  const missing    = outcomes.filter(o => o.status === "missing_on_target");
  const unresolved = outcomes.filter(o => o.status === "unresolved");

  return [
    `${matched} of ${total} tracks ready to import to ${target}.`,
    missing.length    && `${missing.length} not available on ${target}:`,
    ...missing.slice(0, 10).map(o => `  - ${o.title ?? o.sourceId}`),
    unresolved.length && `${unresolved.length} couldn't be identified at all.`,
  ].filter(Boolean).join("\n");
}
4

Hand the IDs to the destination API

From here, each destination platform has its own pattern:

  • Tidal: POST /v1/playlists/{id}/items with Tidal track IDs.
  • Apple Music: MusicKit's library.add() with the song IDs.
  • Spotify: PUT /v1/me/tracks with Spotify URIs.
  • Beatport: Add to cart / collection via their seller API.

Out of scope for this article, but the IDs in matched are exactly the input format each of those endpoints expects. Each destination has its own rate limit — the slow path is usually theirs, not ours.

Going further

  • Use ISRC as the intermediate. If your source data includes ISRC, prefer input_type: "isrc" — slightly better match rate than platform-specific IDs because the ISRC is the canonical key.
  • Fall back on unresolved rows. For rows that come back unresolved, retry with input_type: "track_name"and the artist+title from the source platform's metadata. See missing ISRC fallback patterns.
  • Run nightly delta migrations. Once the initial migration is done, store the source-to-target ID mapping. Any new adds on the source platform go through the same pipeline with one batch per night.

Frequently asked questions

Does this work for any source platform, or only Spotify?

Any source. /v1/tracks/resolve accepts input_type values for spotify_id, applemusic_id, tidal_id, beatport_id, discogs_id, and musicbrainz_id — plus isrc and track_name. The output shape is identical, so the same migration pipeline handles Tidal-to-Apple Music or Beatport-to-Tidal with one config change.

What if a track isn't on the destination platform?

The destination's links field will be missing for that row. Common at ~5–15% depending on genre — Beatport carries electronic that Apple Music skips; Tidal misses some indie that Spotify has. Surface unresolved tracks to the user before importing so they can decide whether to skip or keep manually.

Does SonoVault write to the destination platform for me?

No — SonoVault returns the destination platform IDs. The actual write is via the destination's own API (Tidal API, Apple MusicKit, Spotify Web API, Beatport API, etc.). Each one has its own auth flow and rate limits; this article focuses on getting the right IDs, not the destination side.

How much does a migration cost?

One credit per input track. A typical user library is 500–5,000 tracks, so $0.50–$5 of credit on the standard pricing. Resolving 100,000 tracks for a power-user import costs ~$100 of credits — cheap for a one-time conversion.

Ready to build?

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

Get Free API Key
More in Integration Guides
Integration GuidesListen On / Buy On Buttons from One Track ID6 min read