dj-buyer

star 0

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.

svenflow By svenflow schedule Updated 4/9/2026

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:

  1. Generates multiple query variants (full title, stripped remix/feat info, artist splits, etc.)
  2. Searches each variant on Beatport + Bandcamp
  3. Deduplicates by URL
  4. Sends top 15 candidates to Haiku for verification — Haiku picks the correct match with confidence scoring (high/medium/low/none)
  5. 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:

  1. Filter: Drop anything with similarity < 0.7
  2. 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!)
  3. Platform preference: Always prefer Bandcamp or Beatport over Amazon. Only use Amazon if the track doesn't exist on either Bandcamp or Beatport.
  4. Price rank (within preferred platforms): Bandcamp name-your-price ($0) > Bandcamp minimum ($1) > Beatport MP3 ($1.49) > Beatport WAV ($2.49)
  5. DJ quality tiebreak: If prices are close, prefer Beatport (best metadata + artwork)
  6. 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:

  1. Web search: WebSearch for "Artist" "Title" buy MP3 download
  2. Check results for known platforms:
    • music.amazon.com/tracks/BXXXXXXXXX -> Amazon ASIN exists, purchasable at ~$1.29
    • beatport.com/track/... -> Beatport link, $1.49
    • bandcamp.com links -> Bandcamp, check price on page
    • music.apple.com -> Apple Music/iTunes, note as alternative
  3. Direct Amazon Music lookup: If web search finds an Amazon Music ASIN (e.g. B0D5L6L2XH), navigate directly to https://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:

  1. Enter email: chrome iframe-click <tab> "input[type='email']"chrome insert-text <tab> "$(security find-generic-password -s "assistant" -a "email" -w)" → click Next
  2. 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;"
    
  3. 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

  1. Clear the Amazon cart first — navigate to https://www.amazon.com/gp/cart/view.html and delete any items. Stale cart items can hijack the MP3 purchase flow into standard checkout, causing card declines and account holds.
  2. 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:

  1. Navigate to wallet: https://www.amazon.com/cpe/yourpayments/wallet
  2. Click "Add a payment method" → "Add a credit or debit card"
  3. Card form is in a cross-origin secure iframe — cannot be filled via chrome js or chrome type
  4. Use axctl to 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)
  1. 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
  1. Click "Add your card" button (use axctl get for position, cliclick to click)
  2. 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"):

  1. Go to https://account-status.amazon.com/
  2. Submit verification — upload Privacy.com statement screenshot showing the card
  3. Wait ~5 hours for review (they say 24hrs but it's usually faster)
  4. Root cause: Privacy.com virtual cards trigger Amazon's fraud detection, especially for digital purchases. Failed transactions re-trigger holds.
  5. 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.dbtracks 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)

  1. Beatport search → genre + subgenre directly from track page __NEXT_DATA__ JSON (most authoritative)
  2. Discogs API search → genre + style tags (crowd-sourced)
  3. Last.fm track.getTopTags API → crowd-sourced tags (key in keychain: lastfm-api-key)
  4. 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.db with 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 via response.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:

  1. 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()))
    "
    
  2. Admin opens URL on their phone browser and logs into Spotify
  3. After authorizing, Spotify redirects to http://127.0.0.1:5432/api/spotify_callback?code=XXX which won't load on phone
  4. Admin copies the full redirect URL from their phone's address bar and pastes it back via SMS
  5. 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"
    
  6. 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 screenshot to see what's on screen and adapt.
  • click-by-name sometimes hits StaticText nodes — if a button click doesn't trigger navigation, try iframe-click with CSS selector or text: selector instead.
  • iframe-click is 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 buttonclick-by-name "Download All" clicks the container, not the button. Must use chrome js to target .download-actions button 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, or chrome key. Must use axctl (macOS accessibility API) to set text field values and cliclick with precise axctl-derived coordinates for dropdown menus.
  • Amazon #signInSubmit buttonclick-by-name "Sign in" hits the heading text, not the submit button. Use chrome js with document.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 this
  • search.min_similarity = 0.7 — minimum fuzzy match score
  • search.platforms = ["beatport", "bandcamp", "amazon"]
  • search.preferred_format = "mp3"
Install via CLI
npx skills add https://github.com/svenflow/dispatch --skill dj-buyer
Repository Details
star Stars 0
call_split Forks 3
navigation Branch main
article Path SKILL.md
More from Creator