Beatport Music Search
Purpose
Search Beatport for electronic-music metadata by a free-text query — either an artist name ("deadmau5") or an artist + track combination ("eric prydz opus") — and return structured results across five entity types: tracks, artists, releases, labels, and charts. For each result you get IDs, names, mix names, BPM, musical key, genre, label, release, price, ISRC, artwork URIs, and a canonical Beatport URL. Read-only; never logs in, buys, or downloads.
The recommended path is not scripted browsing. Beatport is a Next.js app whose search page server-side-renders the complete result set into an embedded __NEXT_DATA__ JSON blob. A single HTTP fetch of the search URL (over a residential proxy) returns every field the rendered page shows — no clicking, no pagination, no headless browser. Lead with the fetch path; the browser flow below is a fallback for when you also need screenshots or player interaction.
When to Use
- Look up a track's metadata (BPM, key, genre, label, release, ISRC, price) from an artist + title string.
- Resolve an artist name to a Beatport
artist_idand canonical artist URL, plus their genre spread. - Bulk-enrich a playlist / crate list of "Artist – Title" strings with Beatport IDs and metadata.
- Disambiguate similarly named artists or mixes (the API returns relevance
scoreper result). - Find the release, label, or chart an artist/track belongs to.
Workflow
Recommended: fetch the search page and parse __NEXT_DATA__
-
Build the search URL. URL-encode the whole query into the
qparam:https://www.beatport.com/search?q=<url-encoded query>For artist + track, just join them with a space:
q=eric%20prydz%20opus. The singleqparam drives all five result buckets — there is no separate "artist field" vs "track field". -
Fetch over a residential proxy. Beatport sits behind Cloudflare. The bare homepage (
/) returns 403 to datacenter IPs, but the/searchpage returns 200 over a residential proxy with a normal browser User-Agent. With thebrowseCLI:browse cloud fetch "https://www.beatport.com/search?q=eric%20prydz%20opus" \ --proxies --allow-redirects --output search.htmlNo auth, no cookies, no
--verifiedbrowser session required for the fetch path —--proxiesalone is sufficient (confirmed across multiple queries). Response is ~150–170 KB of HTML. -
Extract the embedded JSON. Pull the
__NEXT_DATA__script tag and walk to the search payload:const m = html.match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/); const next = JSON.parse(m[1]); const data = next.props.pageProps.dehydratedState.queries[0].state.data; // data => { tracks, artists, charts, labels, releases }Each bucket is
{ data: [...], ... }, so the result arrays aredata.tracks.data,data.artists.data,data.releases.data,data.labels.data,data.charts.data. Results are pre-sorted by relevancescore(descending) — element[0]is the best match. -
Decode the fields you need (see Expected Output for full shapes). Key gotchas:
lengthis milliseconds.543453→543.453 s→9:03. Format asmm:ss = floor(ms/60000):round((ms%60000)/1000).genreis an array of{ genre_id, genre_name }(tracks usually have one; artists list many).artists/remixersare arrays of{ artist_id, artist_name, artist_type_name }. Use this to separate the original artist(s) from remixers.priceis an object{ code, symbol, value, display }— useprice.display("$1.49").key_name("A Major"),bpm,isrc,mix_name("Original Mix") are top-level track fields.- Image URIs are absolute (
track_image_uri,artist_image_uri); the*_dynamic_urivariants contain{w}x{h}placeholders you substitute for a custom size.
-
Build canonical URLs. The slug segment is cosmetic — Beatport resolves by the trailing numeric ID, so any slug (or a placeholder) works and redirects to the canonical one:
Track: https://www.beatport.com/track/<slug>/<track_id> Artist: https://www.beatport.com/artist/<slug>/<artist_id> Release: https://www.beatport.com/release/<slug>/<release_id> Label: https://www.beatport.com/label/<slug>/<label_id> Chart: https://www.beatport.com/chart/<slug>/<chart_id>
Browser fallback (only when you need screenshots / player interaction)
The SSR search page is fully JS-hydrated, so a live session needs both stealth flags to clear Cloudflare:
sid=$(browse cloud sessions create --keep-alive --proxies --verified | …)— both--proxiesand--verifiedare required; bare or proxy-only sessions hit the Cloudflare interstitial.browse open "https://www.beatport.com/search?q=<query>" --remote --session "$sid"thenbrowse wait load.- A cookie-consent dialog ("Beatport Group Cookie Consent") overlays the page — dismiss it by clicking the
I Acceptbutton (find its ref viabrowse snapshot) before screenshotting. - Rather than scraping the rendered DOM, just run
browse get html bodyand parse the same__NEXT_DATA__blob described above — the rendered sections (Artists / Releases / Tracks / Charts / Labels) carry no data the JSON doesn't already have.
Site-Specific Gotchas
- Bare homepage 403s; the search page does not. A pre-run probe of
https://beatport.com/returned 403 (Cloudflare). Don't conclude the site is unreachable — go straight to/search?q=…, which returns 200 over a residential proxy. Never gate your flow on a homepage fetch. --proxiesis mandatory,--verifiedis only for the live browser. The fetch path needs the residential proxy (datacenter IPs get Cloudflare-blocked) but no verified browser. The live browser path needs both--proxiesand--verified.- The SSR payload is capped at 15 results per bucket. The embedded query key is
["search-all", { q, count: "15", is_approved: true, preorder: true }, "US"]. You always get the top ~15 tracks / artists / releases / labels / charts. To go deeper you'd need the authenticatedapi.beatport.com/v4API or the per-type search pages — the public search-all SSR does not paginate. lengthis in milliseconds, not seconds. Forgetting this turns a 9-minute track into "543,453 seconds". Divide by 1000.- Results are scored, not alphabetized.
state.data.tracks.data[i].scoreis the relevance score;[0]is the best match. For "artist + track" queries the intended track is reliablytracks.data[0], but verify by matchingartists[].artist_name+mix_nameto your input rather than blindly trusting index 0. - Geo / locale is baked into the payload. The query key's trailing element is the storefront locale (
"US"from a US proxy egress) andpriceis denominated accordingly (USD). A proxy egressing elsewhere returns localized pricing/availability. api.beatport.com/v4/catalog/search/is OAuth-gated — don't waste time on it unauthenticated. It returns HTTP 401 without a bearer token. Confirmed blocked; only useful if you already hold Beatport API credentials.- robots.txt disallows AI/crawler UAs (
ClaudeBot,GPTBot,CCBot, …) butAllow: /for genericUser-agent: *. The residential-proxy fetch uses a normal browser UA, which is in the allowed bucket; search indexing is explicitly permitted (Content-Signal: search=yes). Stay read-only. - URL slugs are throwaway.
https://www.beatport.com/track/anything/15744386resolves to the canonical track by ID — you never need to know the real slug to build a working link. - No site-specific rate limit was hit across ~5 fetches in testing, but Cloudflare fronts everything — keep request volume modest and reuse one proxy session for bulk lookups.
Expected Output
A normalized object per search. Example for q="eric prydz opus":
{
"query": "eric prydz opus",
"locale": "US",
"result_counts": { "tracks": 15, "artists": 15, "releases": 15, "labels": 15, "charts": 15 },
"top_track": {
"track_id": 15744386,
"track_name": "Opus",
"mix_name": "Original Mix",
"artists": [{ "artist_id": 2863, "artist_name": "Eric Prydz", "artist_type_name": "Artist" }],
"remixers": [],
"release": { "release_id": 3517329, "release_name": "Opus" },
"label": { "label_id": 70017, "label_name": "Virgin Records Ltd" },
"genre": [{ "genre_id": 96, "genre_name": "Mainstage" }],
"bpm": 128,
"key_name": "A Major",
"length_ms": 543453,
"length_display": "9:03",
"isrc": "GB6CM1500105",
"price": { "code": "USD", "symbol": "$", "value": 1.49, "display": "$1.49" },
"publish_date": "2016-02-05T00:00:00",
"is_explicit": false,
"track_image_uri": "https://geo-media.beatport.com/image_size/1500x250/....png",
"score": 12345.6,
"url": "https://www.beatport.com/track/opus/15744386"
},
"top_artist": {
"artist_id": 2863,
"artist_name": "Eric Prydz",
"genre": [
{ "genre_id": 15, "genre_name": "Progressive House" },
{ "genre_id": 90, "genre_name": "Melodic House & Techno" }
],
"downloads": 46024,
"latest_publish_date": "2026-01-28",
"artist_image_uri": "https://geo-media.beatport.com/image_size/590x404/....jpg",
"score": 189914.77,
"url": "https://www.beatport.com/artist/eric-prydz/2863"
}
}
Distinct outcome shapes:
- Track match (artist + title query) —
tracks.data[0]is the intended track; populatetop_trackas above. - Artist match (bare artist query) —
artists.data[0]is the artist;top_artistcarries the ID, genre spread, and URL.tracks.datawill also be populated with that artist's most relevant tracks. - Multiple/ambiguous matches — return the full ranked arrays (or top N per bucket) and let the caller disambiguate via
score,mix_name, andartists[].artist_name. - No results — every bucket comes back empty (
tracks.data.length === 0, same for artists/releases/labels/charts). HTTP status is still 200; detect "not found" by empty arrays, not by status code:
{
"query": "zzzxqyqwlkjhgfd",
"locale": "US",
"result_counts": { "tracks": 0, "artists": 0, "releases": 0, "labels": 0, "charts": 0 },
"top_track": null,
"top_artist": null
}