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.
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:
{
"input_type": "spotify_id",
"items": [
"5W3cjX2J3tjhG8zb6u0qHn",
"4uLU6hMCjMI75M1A2tKUQC",
/* up to 100 */
]
}{
"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
}results[j] corresponds to items[j]. That's what makes a positional join with your input list safe.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:
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.
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:
// 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"); }
Hand the IDs to the destination API
From here, each destination platform has its own pattern:
- Tidal:
POST /v1/playlists/{id}/itemswith Tidal track IDs. - Apple Music: MusicKit's
library.add()with the song IDs. - Spotify:
PUT /v1/me/trackswith 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 withinput_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.