Published on July 10, 2026

Music curation means selecting and ordering tracks based on user preferences, context, or algorithmic signals. Every app that generates playlists, powers a "Discover" tab, or personalizes a listening session runs a curation engine under the hood. This post covers the data you need, the algorithms that work in production, and how to pull playlist and track data from multiple streaming services without building separate integrations for each one.
Music curation is the process of filtering, scoring, and ordering tracks to match a listener's taste, mood, or context. It powers playlist generators, recommendation feeds, radio-style queues, and discovery features. Developers build curation engines when their product needs to go beyond static playlists and serve personalized, dynamic track selections at scale.
Three approaches dominate:
| Approach | How It Works | Pros | Cons |
|---|---|---|---|
| Manual | Human editors pick and order tracks | High quality, brand-safe | Does not scale; expensive per playlist |
| Algorithmic | Code scores and ranks tracks using data signals | Scales to millions of users; consistent | Cold-start problem; needs training data |
| Hybrid | Editors set seed tracks or rules; algorithms fill the rest | Balances quality and scale | More complex to build and maintain |
Most production systems use the hybrid approach. Editors define guardrails (genre boundaries, explicit content rules, minimum diversity thresholds), and the algorithm handles ranking and personalization within those constraints.
Your curation engine is only as good as the data feeding it. Here are the fields that matter most, and which major streaming services expose them.
| Data Field | What It Does | Availability |
|---|---|---|
| Genre | Primary filter for mood/context matching | Apple Music (track-level), most others (artist-level only) |
| Tempo / BPM | Workout, party, and focus playlists depend on this | Available via audio analysis endpoints on select services |
| Mood tags | Pre-classified emotional labels | Limited; most services expose this through browse categories |
| Popularity score | Proxy for mainstream appeal; useful for discovery vs. deep cuts | Spotify, Tidal |
| Release date | Recency weighting for "new music" features | All major services |
| Duration | Session-length planning (30-min commute, 1-hr workout) | All major services |
| Audio features | Danceability, energy, valence, acousticness | Select services via extended endpoints |
| Play count / saves | Engagement signal for collaborative filtering | Varies by service; often requires user auth |
The challenge: every service structures this data differently. One returns genre at the track level; another only at the artist level. One provides BPM in a dedicated audio-features object; another buries it in a nested analytics response. Your curation engine needs a consistent data shape to score tracks uniformly.
This is where a normalized API layer pays for itself. Instead of writing parsers for each streaming service's response format, you query one endpoint and get the same JSON structure back every time. Check the supported features matrix to see exactly which fields each service exposes.
Theory is cheap. Here are the three patterns that actually run in production curation engines, with tradeoffs and code.
Collaborative filtering recommends tracks based on what similar users listen to. If User A and User B both love Track 1 and Track 2, and User B also loves Track 3, the system recommends Track 3 to User A.
When to use it: You have user listening data (play history, saves, skips) across a meaningful user base. Works best with 10,000+ active users.
Tradeoffs: Strong at surfacing popular tracks within taste clusters. Weak on new releases (no listening data yet) and niche tracks (too few data points). Requires a user-item interaction matrix that grows with your user base.
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
def collaborative_filter(user_track_matrix, target_user_index, top_n=20):
"""
user_track_matrix: numpy array, rows = users, cols = tracks
Values: play counts, binary (listened/not), or normalized scores
"""
# Calculate similarity between target user and all others
user_similarities = cosine_similarity(
user_track_matrix[target_user_index].reshape(1, -1),
user_track_matrix
).flatten()
# Weight each user's tracks by their similarity to the target
weighted_scores = user_similarities @ user_track_matrix
# Zero out tracks the user already knows
already_listened = user_track_matrix[target_user_index] > 0
weighted_scores[already_listened] = 0
# Return top N track indices
top_track_indices = np.argsort(weighted_scores)[::-1][:top_n]
return top_track_indices
Content-based filtering scores tracks by how similar their attributes are to tracks the user already likes. No other users needed. You compare genre, tempo, energy, mood tags, and audio features directly.
When to use it: You have rich track metadata but limited user-to-user interaction data. Great for new apps, niche audiences, or privacy-first products that avoid cross-user data sharing.
Tradeoffs: Only recommends tracks similar to what the user already listens to. Will not surface genre-crossing discoveries unless you add randomness or diversity constraints.
def content_score(candidate_track, seed_tracks, weights=None):
"""
Score a candidate track against a list of seed tracks.
Each track is a dict with normalized features: genre, energy, tempo, valence, popularity.
"""
if weights is None:
weights = {
"genre_match": 3.0,
"energy_diff": 1.5,
"tempo_diff": 1.0,
"valence_diff": 1.0,
"popularity_diff": 0.5,
}
scores = []
for seed in seed_tracks:
score = 0.0
# Genre match: binary boost
if candidate_track["genre"] == seed["genre"]:
score += weights["genre_match"]
# Continuous features: penalize distance
for feature in ["energy", "tempo", "valence", "popularity"]:
diff = abs(candidate_track[feature] - seed[feature])
score -= diff * weights[f"{feature}_diff"]
scores.append(score)
# Average score across all seed tracks
return sum(scores) / len(scores)
def rank_candidates(candidates, seed_tracks, top_n=20):
scored = [(track, content_score(track, seed_tracks)) for track in candidates]
scored.sort(key=lambda x: x[1], reverse=True)
return [track for track, _ in scored[:top_n]]
Hybrid scoring combines collaborative and content-based signals into a single ranking function. This is what most production curation engines use. You weight each signal based on how much data you have for a given user.
When to use it: Always, if you can. New users get content-based recommendations (cold start). Established users get collaborative filtering boosted by content similarity. The blend shifts automatically based on data availability.
def hybrid_score(track, user_context):
"""
Combine collaborative and content signals with dynamic weighting.
user_context includes: collab_score, content_score, listening_history_size
"""
# More history = more trust in collaborative signal
history_size = user_context["listening_history_size"]
collab_weight = min(history_size / 100, 0.7) # Cap at 70%
content_weight = 1.0 - collab_weight
base_score = (
collab_weight * user_context["collab_score"]
+ content_weight * user_context["content_score"]
)
# Recency boost: newer tracks get a bump
days_since_release = user_context.get("days_since_release", 365)
recency_boost = max(0, 1.0 - (days_since_release / 180)) * 0.15
# Diversity penalty: reduce score if too similar to recent picks
diversity_penalty = user_context.get("similarity_to_recent", 0) * 0.1
return base_score + recency_boost - diversity_penalty
Your curation engine needs raw material: playlists, track metadata, user listening history. If your users connect accounts on multiple streaming services, you need that data from all of them, in a consistent format.
Here is how to pull playlist tracks from any supported service using MusicAPI's endpoints:
const MUSICAPI_BASE = 'https://api.musicapi.com';
async function getPlaylistTracks(userUUID, playlistId, service) {
const response = await fetch(
`${MUSICAPI_BASE}/api/${userUUID}/playlists/${playlistId}/tracks`,
{
headers: {
'Authorization': 'Bearer YOUR_MUSICAPI_TOKEN',
'x-service': service,
},
}
);
return response.json();
}
// Same function works for every service
const spotifyTracks = await getPlaylistTracks(userUUID, playlistId, 'spotify');
const tidalTracks = await getPlaylistTracks(userUUID, playlistId, 'tidal');
const deezerTracks = await getPlaylistTracks(userUUID, playlistId, 'deezer');
Pull a user's saved/favorite tracks to build their taste profile:
async function getFavoriteTracks(userUUID, service) {
const response = await fetch(
`${MUSICAPI_BASE}/api/${userUUID}/liked/tracks`,
{
headers: {
'Authorization': 'Bearer YOUR_MUSICAPI_TOKEN',
'x-service': service,
},
}
);
return response.json();
}
The response shape stays identical whether you query Spotify, SoundCloud, Apple Music, YouTube Music, or any of the other supported services. One parsing function handles all of them.
MusicAPI handles the hard parts that slow down curation engine builds: OAuth flows and token refresh for every service, response normalization across different data formats, and rate limit management so your curation pipeline does not get throttled mid-run. That means you skip weeks of per-service plumbing and focus on the scoring logic that makes your product unique.
Here is a complete curation pipeline that collects user data from multiple services, builds a taste profile, scores candidate tracks, and outputs a ranked playlist.
import asyncio
import aiohttp
MUSICAPI_BASE = "https://api.musicapi.com"
HEADERS = {
"Authorization": "Bearer YOUR_MUSICAPI_TOKEN",
}
async def fetch_user_tracks(session, user_uuid, service):
"""Pull favorite tracks from a single service."""
url = f"{MUSICAPI_BASE}/api/{user_uuid}/liked/tracks"
headers = {**HEADERS, "x-service": service}
async with session.get(url, headers=headers) as resp:
data = await resp.json()
return data.get("tracks", [])
async def fetch_playlist_tracks(session, user_uuid, playlist_id, service):
"""Pull tracks from a specific playlist."""
url = f"{MUSICAPI_BASE}/api/{user_uuid}/playlists/{playlist_id}/tracks"
headers = {**HEADERS, "x-service": service}
async with session.get(url, headers=headers) as resp:
data = await resp.json()
return data.get("tracks", [])
def build_taste_profile(tracks):
"""Extract aggregate feature stats from a user's track collection."""
if not tracks:
return {}
profile = {
"genres": {},
"avg_energy": 0,
"avg_tempo": 0,
"avg_valence": 0,
"track_count": len(tracks),
}
for track in tracks:
genre = track.get("genre", "unknown")
profile["genres"][genre] = profile["genres"].get(genre, 0) + 1
profile["avg_energy"] += track.get("energy", 0.5)
profile["avg_tempo"] += track.get("tempo", 120)
profile["avg_valence"] += track.get("valence", 0.5)
n = len(tracks)
profile["avg_energy"] /= n
profile["avg_tempo"] /= n
profile["avg_valence"] /= n
profile["top_genres"] = sorted(
profile["genres"], key=profile["genres"].get, reverse=True
)[:5]
return profile
def score_and_rank(candidates, taste_profile, top_n=30):
"""Score candidates against the user's taste profile and return top N."""
scored = []
for track in candidates:
score = 0.0
# Genre affinity
genre = track.get("genre", "unknown")
if genre in taste_profile.get("top_genres", []):
genre_rank = taste_profile["top_genres"].index(genre)
score += (5 - genre_rank) * 2.0
# Feature proximity
for feature in ["energy", "tempo", "valence"]:
target = taste_profile.get(f"avg_{feature}", 0.5)
actual = track.get(feature, 0.5)
score -= abs(target - actual) * 1.5
scored.append({"track": track, "score": score})
scored.sort(key=lambda x: x["score"], reverse=True)
return [item["track"] for item in scored[:top_n]]
async def run_curation_pipeline(user_uuid, services, candidate_playlist_id):
"""Full pipeline: gather data, build profile, score, rank."""
async with aiohttp.ClientSession() as session:
# Step 1: Gather user favorites from all connected services
tasks = [
fetch_user_tracks(session, user_uuid, svc) for svc in services
]
results = await asyncio.gather(*tasks)
all_favorites = [track for batch in results for track in batch]
# Step 2: Build taste profile from aggregated favorites
taste_profile = build_taste_profile(all_favorites)
# Step 3: Fetch candidate tracks
candidates = await fetch_playlist_tracks(
session, user_uuid, candidate_playlist_id, services[0]
)
# Step 4: Score and rank
curated = score_and_rank(candidates, taste_profile, top_n=30)
return curated
This pipeline works because MusicAPI returns the same response shape from every service. The fetch_user_tracks function does not care whether it is pulling from Spotify, Tidal, or Deezer. The taste profile builder processes all tracks identically. No per-service conditionals anywhere in the scoring logic.
You can extend this pattern to include collaborative filtering (store user-track matrices in your own database), add diversity constraints (cap tracks per artist, enforce genre variety), or plug in audio feature data for finer-grained scoring.
If you are building a playlist generator, the output of this pipeline feeds directly into MusicAPI's create playlist endpoint to write the curated list back to the user's streaming service.
MusicAPI endpoint response times typically fall between 200ms and 800ms depending on the target service and data volume. Curation latency depends more on your scoring logic than on the API layer. A simple content-based scorer adds less than 50ms for 1,000 candidates. Collaborative filtering with precomputed matrices adds 10-20ms. The bottleneck is usually the initial data fetch, not the ranking step.
MusicAPI fetches data directly from each service's API in real time. There is no stale cache layer between you and the source. When a user adds a track to their favorites on Spotify, your next API call to MusicAPI picks up that change. Playlist modifications, new releases, and metadata updates are reflected on the same cadence as the native service's API.
MusicAPI connects to 10+ services including Spotify, Apple Music, YouTube Music, Tidal, Deezer, Amazon Music, Qobuz, SoundCloud, Audiomack, Audius, Boomplay, and Napster. Each service exposes a different set of data fields (check the supported features matrix for details). Your curation engine gets a normalized response from all of them.
Yes. That is one of the strongest use cases for cross-service curation. A user who listens to jazz on Apple Music and electronic on Spotify reveals broader taste signals than either service alone. Aggregate their favorites from all connected services, build one unified taste profile, and your curation engine produces better recommendations than any single-service algorithm could.
Start with content-based filtering using seed tracks. Ask the user to pick 3-5 tracks or artists they like, pull metadata for those seeds, and score candidates against that initial profile. As the user interacts with your app (plays, skips, saves), shift toward collaborative filtering. The hybrid scoring approach described above handles this transition automatically by weighting each signal based on available data.
That depends on your architecture. For real-time curation with no persistence, you can fetch a user's favorites from MusicAPI on every request and score in-memory. For collaborative filtering, you need to store user-track interaction matrices in your own database. Content-based filtering only needs the track metadata, which you fetch fresh each time. MusicAPI handles the authentication and token storage for connected services, so you do not need to manage OAuth credentials yourself.
Separate the data layer from the scoring layer. Use MusicAPI for all streaming service data fetches (it handles rate limiting and token management per user). Precompute taste profiles and store them in a fast key-value store. Run scoring as a stateless function that reads the profile, fetches candidates, and returns ranked results. This pattern scales horizontally because each request is independent.
Ready to skip months of OAuth and SDK work? Start your free MusicAPI trial and connect 10+ streaming services with one unified API.