name: dj-buyer description: Search and purchase DJ tracks from Bandcamp, Beatport, and Amazon Music using scrapling. Trigger words - dj, track, buy track, search track, bandcamp, beatport, amazon music, purchase music, dj buyer, find track.
DJ Buyer
Search for and purchase DJ tracks across Bandcamp, Beatport, and Amazon Music.
Project: ~/code/dj-buyer/
Run all commands with: cd ~/code/dj-buyer && uv run dj-buyer <command>
Search Workflow
Pre-search: Check if already purchased
ALWAYS check the purchases database before searching or buying a track. This avoids duplicate purchases.
cd ~/code/dj-buyer && sqlite3 ~/.config/dj-buyer/state.db "
SELECT t.artist, t.title, p.platform, p.price, p.purchased_at, p.download_path
FROM purchases p JOIN tracks t ON p.track_id = t.id
WHERE lower(t.artist) LIKE lower('%ARTIST%') AND lower(t.title) LIKE lower('%TITLE%');
"
Also check if the track exists by name (catches imported tracks without Spotify IDs):
cd ~/code/dj-buyer && sqlite3 ~/.config/dj-buyer/state.db "
SELECT id, artist, title, file_path FROM tracks
WHERE lower(artist) LIKE lower('%ARTIST%') AND lower(title) LIKE lower('%TITLE%');
"
Also check if the track file already exists in the library:
ls ~/Music/dj-buyer/*ARTIST*TITLE* 2>/dev/null
If the track is already purchased, skip searching and buying — just report that it's already owned.
Reporting results
ALWAYS show the source platform for every track result. When presenting search results or cost estimates, include the platform name (Bandcamp, Beatport, or Amazon) next to each price. Example format:
✅ Artist - Title: $1.49 (Beatport)
✅ Artist - Title: $1.29 (Amazon)
✅ Artist - Title: $0.00 (Bandcamp, name-your-price)
❌ Artist - Title: NOT FOUND
This helps the user understand where each track will be purchased from and compare options.
Primary: Smart Search with Haiku Verification
ALWAYS use smart_search for track lookups. It handles multi-query generation, cross-platform search, and LLM-verified matching automatically.
# Python (from ~/code/dj-buyer)
cd ~/code/dj-buyer && uv run python -c "
from dj_buyer.smart_search import smart_search
results = smart_search('Artist', 'Title', platforms=['beatport', 'bandcamp'])
for r in results:
print(f'{r.confidence}: {r.result.artist} - {r.result.title} ({r.result.site}, \${r.result.price})')
print(f' Reason: {r.reason}')
"
How it works:
- Generates multiple query variants (full title, stripped remix/feat info, artist splits, etc.)
- Searches each variant on Beatport + Bandcamp
- Deduplicates by URL
- Sends top 15 candidates to Haiku for verification — Haiku picks the correct match with confidence scoring (high/medium/low/none)
- Returns only verified matches above the confidence threshold
Why this is better than raw scraper search:
- Catches remix/VIP mismatches (e.g. won't confuse "Catacombs" with "Catacombs VIP")
- Catches wrong-artist matches (e.g. won't match "DMVU - Dip" to "Davu Flint - Dip")
- Handles Spotify-to-store artist crediting differences (Spotify often lists 1 artist, stores list all collaborators)
- Multi-query variants find tracks that single-query misses
For batch operations (searching many tracks), write a loop:
from dj_buyer.smart_search import smart_search
for track in tracks:
results = smart_search(track['artist'], track['title'], platforms=['beatport', 'bandcamp'])
# Each track takes ~30-60s (multi-variant search + haiku verification)
Manual CLI search (fallback)
Individual platform searches, useful for debugging or one-offs:
cd ~/code/dj-buyer && uv run dj-buyer search-bandcamp "Artist" "Title" --json
cd ~/code/dj-buyer && uv run dj-buyer search-beatport "Artist" "Title" --json
cd ~/code/dj-buyer && uv run dj-buyer search-amazon "Artist" "Title" --json
Or search all at once: cd ~/code/dj-buyer && uv run dj-buyer search "Artist" "Title"
How to Rerank Results (manual search only)
When using manual CLI search (not smart_search), rerank results manually:
- Filter: Drop anything with similarity < 0.7
- Haiku review for 0.7-0.9: For tracks with similarity 0.7-0.9, always run a haiku subagent review pass to verify matches. Common false positive patterns:
- Extra collaborators: Spotify "Caspa" -> Beatport "Caspa, Mythm" (usually correct — just extra credits)
- Wrong artist: "Truth" -> artist named "Transition" (WRONG — different artist entirely)
- Wrong version: "Catacombs" matched to "Catacombs VIP" (WRONG — different version)
- Cover detection: Bandcamp results by different artists with same title (WRONG!)
- Platform preference: Always prefer Bandcamp or Beatport over Amazon. Only use Amazon if the track doesn't exist on either Bandcamp or Beatport.
- Price rank (within preferred platforms): Bandcamp name-your-price ($0) > Bandcamp minimum ($1) > Beatport MP3 ($1.49) > Beatport WAV ($2.49)
- DJ quality tiebreak: If prices are close, prefer Beatport (best metadata + artwork)
- Amazon fallback: Only purchase from Amazon ($1.29) when the track is unavailable on both Bandcamp and Beatport
See search/bandcamp.md, search/beatport.md, search/amazon.md for platform-specific matching guidance.
Fallback: Web search for NOT FOUND tracks
If smart_search returns no results, do a web search before giving up. The scrapers sometimes miss tracks that exist.
Fallback steps:
- Web search:
WebSearchfor"Artist" "Title" buy MP3 download - Check results for known platforms:
music.amazon.com/tracks/BXXXXXXXXX-> Amazon ASIN exists, purchasable at ~$1.29beatport.com/track/...-> Beatport link, $1.49bandcamp.comlinks -> Bandcamp, check price on pagemusic.apple.com-> Apple Music/iTunes, note as alternative
- Direct Amazon Music lookup: If web search finds an Amazon Music ASIN (e.g.
B0D5L6L2XH), navigate directly tohttps://www.amazon.com/dp/ASIN
Never report a track as NOT FOUND without trying smart_search + web search fallback.
Bandcamp Purchase — Step-by-Step Chrome Commands
No CLI command for this. Drive Chrome directly using the chrome-control skill. The UI changes frequently so adapt as needed — take screenshots and read page text to understand what you're looking at.
Payment: PayPal account ($(security find-generic-password -s "assistant" -a "email" -w)) linked to Privacy.com Mastercard. Direct card payment does NOT work (Spreedly rejects Privacy.com BINs).
Billing address: Look up in config.local.yaml or keychain
CSP Warning
Bandcamp and PayPal both have strict CSP. Normal chrome js, chrome click, chrome read, chrome find all fail. You MUST use these CSP-bypass commands:
| Command | Use for | Notes |
|---|---|---|
chrome iframe-click <tab> <selector> |
Click elements via CSS selector or text:XXX |
Returns {'success': True} or {} if not found |
chrome insert-text <tab> <text> |
Type into focused input | Must click/focus first |
chrome click-by-name <tab> <name> |
Click by accessible name | Uses accessibility API, best for buttons |
chrome text <tab> |
Read page text | Works despite CSP |
chrome html <tab> |
Get full page HTML | Works despite CSP |
chrome screenshot <tab> |
Take screenshot | For debugging |
chrome key <tab> <key> |
Send keypress (Tab, Return, etc) | Works despite CSP |
For native <select> dropdowns (like city selector), none of the above work. Use debugger-eval via direct socket:
import json, socket, glob
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(glob.glob("/tmp/chrome_control_*.sock")[0])
sock.settimeout(30)
code = """
var sel = document.querySelector('select[name="city"]');
sel.value = sel.options[1].value;
sel.dispatchEvent(new Event('change', {bubbles: true}));
'done'
"""
sock.sendall(json.dumps({"command": "debugger_eval", "params": {"tabId": TAB_ID, "code": code}}).encode())
data = b""
while b"\n" not in data:
data += sock.recv(65536)
sock.close()
Step 1: Open track page
chrome open "<bandcamp_url>"
# Returns tab ID, e.g. "Opened tab 1234567: https://..."
Wait 5-6 seconds for page load. Dismiss cookie banner if present:
chrome iframe-click <tab> "text:Accept all"
Step 2: Click "Buy Digital Track"
chrome iframe-click <tab> "text:Buy Digital Track"
If it returns {}, retry after 2 seconds. May also try chrome click-by-name <tab> "Buy Digital Track".
This opens a purchase dialog/modal/sidebar (UI varies).
Step 3: Set price (optional, for name-your-price)
If the track is name-your-price and you want to set a custom amount:
chrome iframe-click <tab> "#userPrice" # or input[name='userPrice']
chrome key <tab> "a" "meta" # Select all
chrome insert-text <tab> "1.00" # Type price
chrome key <tab> "Tab" # Tab out to update
Step 4: Navigate to checkout
Look for "Check out" or "Check out now" button. Use chrome text or chrome screenshot to see what's on screen.
chrome iframe-click <tab> "#sidecartCheckout" # Cart sidebar link
# OR
chrome iframe-click <tab> "text:Check out now" # Modal button
# OR
chrome iframe-click <tab> "text:Check out with PayPal" # Direct PayPal option
WARNING: click-by-name "Check out" often hits a StaticText node instead of the actual link/button. Prefer iframe-click with CSS selectors or text: selectors.
Step 5: Fill billing info
On the checkout/billing page, fill ZIP code and select city:
chrome iframe-click <tab> "input[id*='zip']" # Focus ZIP input
chrome key <tab> "a" "meta" # Select all
chrome insert-text <tab> "02135" # Type ZIP
chrome key <tab> "Tab" # Tab out
Wait 2-3 seconds for the city dropdown to populate, then set city via debugger_eval (see socket code above). Look for a <select> with city options containing "Boston".
Step 6: Proceed to PayPal
chrome iframe-click <tab> "text:Proceed to PayPal"
# OR
chrome click-by-name <tab> "Proceed to PayPal"
Wait 8-10 seconds for PayPal redirect.
Step 7: PayPal login (if needed)
Check chrome text <tab> — if PayPal shows "Complete Purchase" or "Pay Now", you're already logged in (skip to Step 8).
If PayPal asks for login:
- Enter email:
chrome iframe-click <tab> "input[type='email']"→chrome insert-text <tab> "$(security find-generic-password -s "assistant" -a "email" -w)"→ click Next - PayPal will send OTP via SMS to shortcode
70924. Read it from Messages.app:sqlite3 "file:$HOME/Library/Messages/chat.db?mode=ro" \ "SELECT text FROM message WHERE handle_id IN (SELECT ROWID FROM handle WHERE id LIKE '%70924%') ORDER BY date DESC LIMIT 1;" - Enter OTP code and submit
Step 8: Complete purchase
chrome click-by-name <tab> "Complete Purchase"
# OR try: "Pay Now", "Agree & Pay", "Continue"
Wait 10-15 seconds for redirect back to Bandcamp.
Step 9: Download MP3 V0
After purchase, get the download URL from the Bandcamp confirmation email:
# Search for latest Bandcamp receipt email
~/.local/bin/gws gmail users messages list --params '{"q": "from:bandcamp subject:Thank", "maxResults": 1, "userId": "me"}'
# Get the message body (extract text/plain part, base64 decode)
~/.local/bin/gws gmail users messages get --params '{"userId": "me", "id": "<MSG_ID>", "format": "full"}'
The email body contains a download URL like:
https://bandcamp.com/download?from=receipt&payment_id=XXXXX&sig=XXXXX
Open it in Chrome, get the HTML, and extract the direct download link:
chrome open "<download_url>"
# Wait 5 seconds
chrome html <tab>
# Parse HTML for: https://p*.bcbits.com/download/track/*/mp3-v0/*
The <select id="format-type"> has options. MP3 V0 (value="mp3-v0") is the first/default option.
Download via curl:
mkdir -p ~/Music/dj-buyer
curl -L -o ~/Music/dj-buyer/"Artist - Title.mp3" "<direct_bcbits_url>"
Always download MP3 V0 — highest quality VBR MP3.
Verify the file:
file ~/Music/dj-buyer/"Artist - Title.mp3"
# Should show: Audio file with ID3 version 2.3.0, contains: MPEG ADTS, layer III
Close the Chrome tabs when done.
Beatport Purchase — Step-by-Step Chrome Commands
No CLI command for this. Drive Chrome directly using the chrome-control skill. Beatport has NO CSP issues — chrome js works fine.
Payment: PayPal (selected by default at checkout). Privacy.com card also works directly with Beatport.
Credentials: Stored in macOS Keychain:
security find-generic-password -a "sven" -s "beatport-email" -w
security find-generic-password -a "sven" -s "beatport-password" -w
Step 1: Open track page
chrome open "<beatport_url>"
# Returns tab ID, e.g. "Opened tab 1234567: https://..."
Wait 3-4 seconds for page load.
Step 2: Add to cart
The price button's accessible name includes the price and track info. Use click-by-name with just the price:
chrome click-by-name <tab> "$1.49"
# OR for WAV: chrome click-by-name <tab> "$2.49"
If there are multiple format options, click the one you want (MP3 is default/cheaper).
Step 3: Go to cart
chrome navigate <tab> "https://www.beatport.com/cart"
Wait 2-3 seconds for cart page load.
Step 4: Checkout
chrome iframe-click <tab> "text:Checkout"
NOTE: click-by-name "Checkout" may hit the cell/container instead of the actual button. Use iframe-click with text:Checkout for reliability.
PayPal is selected by default. Wait 8-10 seconds for PayPal to load.
Step 5: PayPal (usually auto-logged in)
Check chrome text <tab> to see the PayPal state:
- If it shows "Review Order" or "Complete Purchase" → already logged in
- If it asks for login → follow the PayPal login steps from the Bandcamp section above
chrome click-by-name <tab> "Review Order"
# Wait 3-5 seconds
chrome click-by-name <tab> "Complete Purchase"
# OR: "Pay Now", "Agree & Pay"
Wait 10-15 seconds for redirect back to Beatport "Thank You" page.
Step 6: Download MP3
Navigate to the downloads library:
chrome navigate <tab> "https://www.beatport.com/library/downloads"
Wait 3-4 seconds for page load. Then trigger the download via JS (Beatport has no CSP):
chrome js <tab> "document.querySelectorAll('.download-actions')[1].querySelector('button').click()"
Why [1]? Index [0] is the "Download All" header row. Index [1] is the first actual track. For multiple tracks, increment the index.
The file downloads to ~/Downloads/. Move it to the music directory:
mkdir -p ~/Music/dj-buyer
mv ~/Downloads/"Artist - Title.mp3" ~/Music/dj-buyer/
Beatport downloads are 320kbps CBR MP3 (their standard format, not VBR like Bandcamp's V0).
Verify the file:
file ~/Music/dj-buyer/"Artist - Title.mp3"
# Should show: Audio file with ID3 version 2.3.0, contains: MPEG ADTS, layer III
Close the Chrome tabs when done.
Amazon Purchase — Step-by-Step
Only use Amazon as a fallback when the track isn't available on Bandcamp or Beatport. Amazon is $1.29/track, 256kbps VBR MP3.
Credentials: Stored in macOS Keychain:
# Email: $(security find-generic-password -s "assistant" -a "email" -w)
security find-generic-password -s "amazon-password" -w
Payment: Privacy.com Mastercard (details in keychain: privacy-card-number). Billing address in keychain.
Pre-flight checks
- Clear the Amazon cart first — navigate to
https://www.amazon.com/gp/cart/view.htmland delete any items. Stale cart items can hijack the MP3 purchase flow into standard checkout, causing card declines and account holds. - Price guardrail — any purchase over $10 should STOP and ask for confirmation. Single tracks are $1-2. Anything higher means something is wrong (cart contamination, wrong product, etc).
Step 1: Search and open track
Search Amazon Digital Music for the track:
chrome navigate <tab> "https://www.amazon.com/s?k=ARTIST+TITLE&i=digital-music"
Find the product link via JS:
chrome js <tab> "document.querySelector('a[href*=\"/dp/B\"]')?.href"
Navigate to the product page (Amazon Music player view):
chrome navigate <tab> "https://www.amazon.com/dp/<ASIN>"
Step 2: Sign in (if needed)
If header shows "Hello, sign in":
# Click account on sign-in page, then enter password
chrome read <tab> # Find password textbox ref
chrome type <tab> <ref> "<password_from_keychain>"
chrome click <tab> <sign_in_button_ref>
NOTE: click-by-name "Sign in" may hit the heading text, not the submit button. Use chrome js with document.querySelector('#signInSubmit').click() as fallback.
Amazon may ask for phone number ("Keep hackers out") — click "Not now" to skip.
Step 3: Purchase via Purchase Options menu
chrome click-by-name <tab> "Purchase Options"
# Wait 1 second for dropdown
chrome click-by-name <tab> "MP3 Music"
# Wait 3 seconds — redirects to "Review MP3 purchase" page
This shows: track name, artist, Order total: $1.29, and two buy buttons:
- "Buy MP3 Album - Pay Now" (album-level)
- "BUY MP3 SONG - PAY NOW" (song-level)
Step 4: Confirm purchase
chrome click-by-name <tab> "Buy MP3 Album - Pay Now"
# or for single song:
chrome click-by-name <tab> "Buy MP3 Song - Pay Now"
If the billing address error appears ("There was a problem with this order"):
chrome click-by-name <tab> "Continue"
This usually processes the payment on the second attempt.
Wait 5 seconds. Success page shows: "Thank you for shopping with us" with "Download" button.
Step 5: Download MP3
Click the Download button on the confirmation page:
chrome click-by-name <tab> "Download"
Or navigate to purchase history:
chrome navigate <tab> "https://www.amazon.com/gp/dmusic/purchases"
Amazon provides 256kbps VBR MP3 files. Downloaded to ~/Downloads/.
mv ~/Downloads/"*.mp3" ~/Music/dj-buyer/"Artist - Title.mp3"
Payment method management
The Privacy.com card may get removed during account holds. To re-add:
- Navigate to wallet:
https://www.amazon.com/cpe/yourpayments/wallet - Click "Add a payment method" → "Add a credit or debit card"
- Card form is in a cross-origin secure iframe — cannot be filled via
chrome jsorchrome type - Use
axctlto fill the form fields:
CARD_NUM=$(security find-generic-password -s "privacy-card-number" -w)
CARD_CVV=$(security find-generic-password -s "privacy-card-cvv" -w)
# Fill text fields
axctl type "Google Chrome" --title "Card number" "$CARD_NUM"
axctl type "Google Chrome" --title "Name on card" "$(security find-generic-password -s 'privacy-card-name' -w)"
axctl type "Google Chrome" --role AXTextField --index 4 "$CARD_CVV" # CVV (unnamed field)
- Expiration dropdowns require precise coordinate clicking:
# Get dropdown positions via axctl
axctl get "Google Chrome" --title "Expiration date" AXPosition # Month dropdown
axctl get "Google Chrome" --role AXPopUpButton --index 6 AXPosition # Year dropdown
# Click to open dropdown, then find and click the right menu item
cliclick c:<center_x>,<center_y> # Open dropdown
axctl search "Google Chrome" --role AXMenuItem # Find items with positions
axctl get "Google Chrome" --role AXMenuItem --index <N> AXPosition # Get exact position
cliclick c:<item_center_x>,<item_center_y> # Click the menu item
- Click "Add your card" button (use
axctl getfor position,cliclickto click) - Set billing address: Edit card → "Choose or add a billing address" → use the billing address from keychain → Save
Amazon account hold recovery
If the account goes on hold ("Account on hold temporarily"):
- Go to
https://account-status.amazon.com/ - Submit verification — upload Privacy.com statement screenshot showing the card
- Wait ~5 hours for review (they say 24hrs but it's usually faster)
- Root cause: Privacy.com virtual cards trigger Amazon's fraud detection, especially for digital purchases. Failed transactions re-trigger holds.
- After hold is lifted, you must re-add the payment card and billing address (they get wiped)
Genre Classification
All tracks in the library get classified into the Beatport genre taxonomy. Genres use breadcrumb format: "Drum & Bass > Liquid", "Techno (Raw / Deep / Hypnotic) > Dub".
Database: ~/.config/dj-buyer/state.db — tracks table has genre, genre_source, and genre_old columns.
Taxonomy: Scraped from Beatport's v4 API, saved at ~/code/dj-buyer/data/beatport_taxonomy.json. 45+ genres with 130+ subgenres. The classifier script also has an inline copy of the taxonomy for the Haiku prompt.
Architecture (2-phase: evidence gathering → LLM mapping)
Phase 1: Gather evidence (pure code, no LLM)
- Beatport search → genre + subgenre directly from track page
__NEXT_DATA__JSON (most authoritative) - Discogs API search → genre + style tags (crowd-sourced)
- Last.fm
track.getTopTagsAPI → crowd-sourced tags (key in keychain:lastfm-api-key) - Spotify artist genres (already in DB from import)
Phase 2: Map to taxonomy (Haiku, grounded)
- If Beatport found the exact track → use its genre directly, no LLM needed
- If only Last.fm/Discogs/Spotify data → Haiku maps external tags to the nearest Beatport taxonomy entry
- Haiku MUST cite which source it used and output confidence (high/medium/low/none)
- If no source has useful data →
null, not a guess
Key principle: Haiku never invents a genre. It only does semantic mapping between external tags and the fixed taxonomy. It's a translator, not an oracle.
Running the classifier
cd ~/code/dj-buyer
# Classify all tracks
uv run python scripts/genre_classify.py
# Only unclassified tracks
uv run python scripts/genre_classify.py --missing
# Limit + dry run
uv run python scripts/genre_classify.py --missing --limit 20 --dry-run
# Set minimum confidence threshold
uv run python scripts/genre_classify.py --min-confidence high
Checking genre stats
# Total classified vs unclassified
sqlite3 ~/.config/dj-buyer/state.db "SELECT count(*) as total, count(genre) as with_genre, count(*) - count(genre) as missing FROM tracks;"
# Genre distribution
sqlite3 ~/.config/dj-buyer/state.db "SELECT genre, count(*) as n FROM tracks WHERE genre IS NOT NULL GROUP BY genre ORDER BY n DESC LIMIT 20;"
# Sample random classified tracks
sqlite3 ~/.config/dj-buyer/state.db "SELECT artist, title, genre FROM tracks WHERE genre IS NOT NULL ORDER BY RANDOM() LIMIT 25;"
Last.fm API
API key stored in macOS Keychain: security find-generic-password -s "lastfm-api-key" -w
# Get top tags for a track
curl -s "http://ws.audioscrobbler.com/2.0/?method=track.getTopTags&artist=ARTIST&track=TITLE&api_key=API_KEY&format=json"
# Get top tags for an artist
curl -s "http://ws.audioscrobbler.com/2.0/?method=artist.getTopTags&artist=ARTIST&api_key=API_KEY&format=json"
Important notes
- DB path: ALWAYS use
~/.config/dj-buyer/state.db— there's a stale~/code/dj-buyer/state.dbwith only test data. Never query the code-dir one. - genre_source:
"haiku_verified"for LLM-classified, direct Beatport matches also get this tag currently - genre_old: backup of previous genre before reclassification (for rollback)
- import- tracks*: tracks with
spotify_id LIKE 'import-%'are skipped by the classifier (manual imports without Spotify IDs)
Scrapling Architecture
Fetcher: HTTP-only with TLS fingerprinting. Used for search. Access HTML viaresponse.html_content(NOT.text).
CRITICAL: Use response.html_content not response.text. Scrapling's Fetcher returns empty string for .text but the actual HTML is in .html_content (decoded from .body bytes).
Payment
Primary: PayPal account ($(security find-generic-password -s "assistant" -a "email" -w)) linked to Privacy.com Mastercard. Used for Bandcamp.
Backup: Privacy.com virtual card details in macOS Keychain:
security find-generic-password -a "sven" -s "privacy-card-number" -w
security find-generic-password -a "sven" -s "privacy-card-exp" -w # MM/YY format
security find-generic-password -a "sven" -s "privacy-card-cvv" -w
Note: Privacy.com card works directly with Beatport/Amazon but NOT with Bandcamp's Spreedly processor (BIN rejection).
Spotify Integration
The daemon watches a Spotify playlist for new tracks and auto-searches all platforms.
cd ~/code/dj-buyer && uv run dj-buyer list-tracks <playlist_id>
cd ~/code/dj-buyer && uv run dj-buyer poll
cd ~/code/dj-buyer && uv run dj-buyer auth # Re-auth if token expired
Current playlist: 162TAg29u887r6VksnVf5d (configured in config.toml)
Current playlist: 162TAg29u887r6VksnVf5d — "pmtest2" (configured in config.toml)
Spotify Track Metadata
All metadata available via Spotify API for a track. Use sp.track(track_id) to get:
Track-level fields:
| Field | Type | Example | Notes |
|---|---|---|---|
name |
string | "Breaker" | Track title |
id |
string | "4scsWxtNAeT4kW52xOJdCg" | Spotify track ID |
uri |
string | "spotify:track:..." | Spotify URI |
duration_ms |
int | 168857 | Duration in milliseconds |
popularity |
int | 32 | 0-100, based on recent plays |
explicit |
bool | false | Explicit content flag |
disc_number |
int | 1 | Disc number |
track_number |
int | 5 | Track number on album |
is_local |
bool | false | Local file flag |
preview_url |
string/null | null | 30s preview MP3 (often null now) |
external_ids.isrc |
string | "QZTGW2407468" | ISRC code (international standard recording code) |
external_urls.spotify |
string | "https://open.spotify.com/track/..." | Spotify web URL |
Artist fields (via sp.artist(artist_id)):
| Field | Type | Example | Notes |
|---|---|---|---|
name |
string | "LYNY" | Artist name |
id |
string | "7xqIp1044Z2vd9v9ZphjLa" | Spotify artist ID |
genres |
list[str] | ["bass music"] | Genre tags (can be empty) |
popularity |
int | 56 | 0-100 |
followers.total |
int | 38007 | Follower count |
images |
list | [{url, width, height}] | 640px, 320px, 160px |
Album fields (via sp.album(album_id)):
| Field | Type | Example | Notes |
|---|---|---|---|
name |
string | "Noise To Dance To" | Album title |
album_type |
string | "album" | "album", "single", "compilation" |
release_date |
string | "2025-10-17" | Release date |
release_date_precision |
string | "day" | "year", "month", or "day" |
total_tracks |
int | 12 | Number of tracks |
label |
string | "Noxious Recordings" | Record label |
popularity |
int | 43 | 0-100 |
external_ids.upc |
string | "663918559468" | UPC barcode |
copyrights |
list | [{text, type}] | C = copyright, P = phonogram |
images |
list | [{url, width, height}] | 640px, 300px, 64px album art |
genres |
list[str] | [] | Album genres (usually empty, use artist genres) |
Working Spotify API Endpoints
| Endpoint | Method | Notes |
|---|---|---|
sp.track(id) |
GET track | Full track metadata |
sp.tracks([ids]) |
GET tracks (batch) | Up to 50 track IDs at once |
sp.artist(id) |
GET artist | Artist metadata + genres + followers |
sp.artist_top_tracks(id) |
GET top tracks | Top 10 tracks by popularity |
sp.artist_albums(id) |
GET discography | All albums/singles/compilations |
sp.album(id) |
GET album | Full album metadata + copyrights + label |
sp.album_tracks(id) |
GET album tracks | Track listing for an album |
sp.search(q, type) |
Search | Types: track, artist, album, playlist |
sp.playlist(id) |
GET playlist | Playlist name, tracks, owner |
sp.current_user_saved_tracks() |
Library | User's liked songs |
sp.me() |
Current user | User profile (premium status, country) |
Deprecated/Blocked Spotify Endpoints (403/404)
These endpoints are no longer available for most apps:
| Endpoint | Status | Notes |
|---|---|---|
sp.audio_features(ids) |
403 | BPM, key, energy, danceability — deprecated Nov 2024 |
sp.audio_analysis(id) |
403 | Detailed beat/bar/section analysis — deprecated Nov 2024 |
sp.recommendations(seed_tracks) |
404 | Track recommendations — removed |
sp.artist_related_artists(id) |
404 | Similar artists — removed |
sp.current_user_recently_played() |
403 | Needs user-read-recently-played scope (not in our auth) |
sp.current_user_top_tracks() |
403 | Needs user-top-read scope (not in our auth) |
Recco Beats API (BPM, Key, Energy — Spotify Replacement)
Free API at api.reccobeats.com — no auth needed. Accepts Spotify track IDs and returns the audio features Spotify deprecated.
Track metadata:
curl -s "https://api.reccobeats.com/v1/track?ids=SPOTIFY_ID1,SPOTIFY_ID2"
Returns: trackTitle, artists, durationMs, ISRC, EAN, UPC, popularity, availableCountries
Audio features (BPM, key, energy, etc.):
curl -s "https://api.reccobeats.com/v1/audio-features?ids=SPOTIFY_ID1,SPOTIFY_ID2"
Returns per track:
| Field | Type | Example | Notes |
|---|---|---|---|
tempo |
float | 139.986 | BPM |
key |
int | 1 | Pitch class (0=C, 1=C#/Db, 2=D, ..., 11=B) |
mode |
int | 1 | 0=minor, 1=major |
energy |
float | 0.967 | 0.0-1.0, intensity/activity |
danceability |
float | 0.706 | 0.0-1.0, how danceable |
valence |
float | 0.939 | 0.0-1.0, musical positiveness |
acousticness |
float | 0.567 | 0.0-1.0, acoustic confidence |
instrumentalness |
float | 0.00983 | 0.0-1.0, no vocals confidence |
liveness |
float | 0.204 | 0.0-1.0, live audience presence |
loudness |
float | -3.888 | dB, overall loudness |
speechiness |
float | 0.214 | 0.0-1.0, spoken words presence |
Batch limit: 50 IDs per request. No API key needed. Results in content array.
Key mapping: 0=C, 1=C#/Db, 2=D, 3=D#/Eb, 4=E, 5=F, 6=F#/Gb, 7=G, 8=G#/Ab, 9=A, 10=A#/Bb, 11=B. Combine with mode for full key (e.g. key=1, mode=1 → C# major).
Spotify Token Management
Token storage: Access token, refresh token, and expiry are in ~/code/dj-buyer/state.db (table: spotify_auth). Refresh token is also backed up to macOS Keychain under service spotify-refresh-token, account dj-buyer.
Auto-refresh: get_spotify_client() automatically refreshes the access token when it's within 5 minutes of expiry using the stored refresh token. No manual intervention needed.
Token health check: Before running Spotify operations in background/ephemeral tasks, validate the token first. The re-auth flow requires manual SMS URL exchange which breaks in background contexts (exit code 144 / SIGTERM timeout). If the token is expired and refresh fails, proactively notify admin via SMS with the re-auth URL rather than silently failing. Don't attempt full re-auth in background tasks.
Refresh token recovery from keychain:
security find-generic-password -a "dj-buyer" -s "spotify-refresh-token" -w
Spotify Re-auth Flow (when refresh token is revoked)
The redirect URI registered in the Spotify app is http://127.0.0.1:5432/api/spotify_callback (shared with playlist-manager, same client_id 43d9bf46f34d48bb80cc23803c8db2a8). CRITICAL: Must use 127.0.0.1 not localhost — Spotify checks exact URI match.
When the refresh token is revoked and you need fresh auth:
- Generate the auth URL and send it to the admin via SMS:
cd ~/code/dj-buyer && uv run python -c " from src.dj_buyer.config import Config from src.dj_buyer.spotify.auth import get_auth_url print(get_auth_url(Config.load())) " - Admin opens URL on their phone browser and logs into Spotify
- After authorizing, Spotify redirects to
http://127.0.0.1:5432/api/spotify_callback?code=XXXwhich won't load on phone - Admin copies the full redirect URL from their phone's address bar and pastes it back via SMS
- Extract the
code=parameter and exchange it for tokens:CLIENT_SECRET=$(cd ~/code/dj-buyer && uv run python -c "from src.dj_buyer.config import get_spotify_secret; print(get_spotify_secret())") CREDS=$(echo -n "43d9bf46f34d48bb80cc23803c8db2a8:$CLIENT_SECRET" | base64) curl -s -X POST "https://accounts.spotify.com/api/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -H "Authorization: Basic $CREDS" \ -d "grant_type=authorization_code&code=AUTH_CODE&redirect_uri=http://127.0.0.1:5432/api/spotify_callback" - Save tokens to state.db and keychain:
EXPIRES_AT=$(($(date +%s) + 3600)) sqlite3 ~/code/dj-buyer/state.db "DELETE FROM spotify_auth; INSERT INTO spotify_auth (access_token, refresh_token, expires_at) VALUES ('ACCESS_TOKEN', 'REFRESH_TOKEN', $EXPIRES_AT);" security add-generic-password -a "dj-buyer" -s "spotify-refresh-token" -w "REFRESH_TOKEN" -U
Important: The refresh token doesn't expire on its own — only if the user revokes access or the app credentials change. Once you have it, auto-refresh handles everything.
Price Safety Guardrail
CRITICAL: Before completing ANY purchase, verify the total is under $10. Single DJ tracks cost $0-$2.49 typically. If the checkout total exceeds $10, STOP immediately and ask the admin for confirmation before proceeding. This catches:
- Cart items from other sessions leaking into the purchase flow
- Wrong product selected (album instead of track, physical instead of digital)
- Currency/pricing errors
- Accidental duplicate purchases
Never auto-confirm a purchase over $10. Even if the user asked you to buy a specific track, if the checkout shows >$10, something is wrong.
Known Issues
- Bandcamp UI changes frequently — the purchase flow (modal vs sidebar, button labels, etc.) changes without notice. Always use
chrome text/chrome screenshotto see what's on screen and adapt. click-by-namesometimes hits StaticText nodes — if a button click doesn't trigger navigation, tryiframe-clickwith CSS selector ortext:selector instead.iframe-clickis timing-sensitive — if it returns{}, wait 2 seconds and retry.- Privacy.com BIN rejection — direct card payment on Bandcamp doesn't work. Must use PayPal.
- PayPal hCaptcha — headless browsers (StealthyFetcher/Playwright) trigger bot detection. Must use real Chrome.
- Native
<select>dropdowns — can't be set via CDP keyboard or iframe-click. Must use debugger_eval socket. - Beatport download button —
click-by-name "Download All"clicks the container, not the button. Must usechrome jsto target.download-actionsbutton directly. - Amazon cross-origin card form — the "Add a credit or debit card" modal uses a cross-origin secure iframe. Cannot fill via
chrome js,chrome type, orchrome key. Must useaxctl(macOS accessibility API) to set text field values andcliclickwith preciseaxctl-derived coordinates for dropdown menus. - Amazon
#signInSubmitbutton —click-by-name "Sign in"hits the heading text, not the submit button. Usechrome jswithdocument.querySelector('#signInSubmit').click(). - Amazon account holds — Privacy.com virtual cards trigger Amazon's fraud detection. Failed transactions re-trigger holds. See "Amazon account hold recovery" section for resolution steps.
- Amazon cart contamination — existing items in the Amazon cart can redirect the Amazon Music "Buy MP3" flow to standard checkout with inflated totals. Always clear the cart before purchasing music (see pre-flight checks).
- Amazon billing address — after re-adding the Privacy.com card, you must also set the billing address via Edit card → "Choose or add a billing address". Without it, purchases fail with "billing address must match your country of purchase".
Post-Download: BPM & Key Enrichment
After downloading any track, basic ID3 tags (artist, title, artwork) are written automatically. BPM and musical key are also auto-enriched on import via the Recco Beats API — no manual step needed for new purchases.
The Recco Beats API (free, no auth) is called automatically during the purchase/download flow using the track's Spotify ID as the lookup key. Tags are written to the MP3 immediately.
Manual Re-Enrichment (bulk / existing library only)
Only needed if you have older tracks missing BPM/key, or want to re-enrich in bulk:
cd ~/code/dj-buyer
# Step 1: Fetch BPM/key from Recco Beats API and store in DB
uv run dj-buyer library enrich
# Step 2: Write enriched tags back to the MP3 files
uv run dj-buyer library retag
Without enrichment, files are missing BPM and key in DJ software (Rekordbox, Serato, etc.).
Config
~/code/dj-buyer/config.toml:
search.max_price = 15.00— skip anything over thissearch.min_similarity = 0.7— minimum fuzzy match scoresearch.platforms = ["beatport", "bandcamp", "amazon"]search.preferred_format = "mp3"