skills/dj-buyer/SKILL.md
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.
npx skillsauth add svenflow/dispatch dj-buyerInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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>
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.
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.
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:
Why this is better than raw scraper search:
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)
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"
When using manual CLI search (not smart_search), rerank results manually:
See search/bandcamp.md, search/beatport.md, search/amazon.md for platform-specific matching guidance.
If smart_search returns no results, do a web search before giving up. The scrapers sometimes miss tracks that exist.
Fallback steps:
WebSearch for "Artist" "Title" buy MP3 downloadmusic.amazon.com/tracks/BXXXXXXXXX -> Amazon ASIN exists, purchasable at ~$1.29beatport.com/track/... -> Beatport link, $1.49bandcamp.com links -> Bandcamp, check price on pagemusic.apple.com -> Apple Music/iTunes, note as alternativeB0D5L6L2XH), navigate directly to https://www.amazon.com/dp/ASINNever report a track as NOT FOUND without trying smart_search + web search fallback.
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
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()
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"
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).
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
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.
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".
chrome iframe-click <tab> "text:Proceed to PayPal"
# OR
chrome click-by-name <tab> "Proceed to PayPal"
Wait 8-10 seconds for PayPal redirect.
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:
chrome iframe-click <tab> "input[type='email']" → chrome insert-text <tab> "$(security find-generic-password -s "assistant" -a "email" -w)" → click Next70924. 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;"
chrome click-by-name <tab> "Complete Purchase"
# OR try: "Pay Now", "Agree & Pay", "Continue"
Wait 10-15 seconds for redirect back to Bandcamp.
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.
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
chrome open "<beatport_url>"
# Returns tab ID, e.g. "Opened tab 1234567: https://..."
Wait 3-4 seconds for page load.
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).
chrome navigate <tab> "https://www.beatport.com/cart"
Wait 2-3 seconds for cart page load.
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.
Check chrome text <tab> to see the PayPal state:
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.
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.
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.
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.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>"
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.
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:
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.
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"
The Privacy.com card may get removed during account holds. To re-add:
https://www.amazon.com/cpe/yourpayments/walletchrome js or chrome typeaxctl 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)
# 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
axctl get for position, cliclick to click)If the account goes on hold ("Account on hold temporarily"):
https://account-status.amazon.com/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.
Phase 1: Gather evidence (pure code, no LLM)
__NEXT_DATA__ JSON (most authoritative)track.getTopTags API → crowd-sourced tags (key in keychain: lastfm-api-key)Phase 2: Map to taxonomy (Haiku, grounded)
null, not a guessKey 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.
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
# 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;"
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"
~/.config/dj-buyer/state.db — there's a stale ~/code/dj-buyer/state.db with only test data. Never query the code-dir one."haiku_verified" for LLM-classified, direct Beatport matches also get this tag currentlyspotify_id LIKE 'import-%' are skipped by the classifier (manual imports without Spotify IDs)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).
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).
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)
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) |
| 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) |
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) |
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).
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
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:
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()))
"
http://127.0.0.1:5432/api/spotify_callback?code=XXX which won't load on phonecode= 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"
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.
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:
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.
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.<select> dropdowns — can't be set via CDP keyboard or iframe-click. Must use debugger_eval socket.click-by-name "Download All" clicks the container, not the button. Must use chrome js to target .download-actions button directly.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.#signInSubmit button — click-by-name "Sign in" hits the heading text, not the submit button. Use chrome js with document.querySelector('#signInSubmit').click().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.
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.).
~/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"development
Use when building React/Next.js components, dashboards, admin panels, apps, or any web interface. Trigger words - react, frontend, ui, dashboard, component, interface, web app, polish, audit, design review.
tools
Track flight status and get FlightAware links. Use when asked about flights, flight status, arrival times, or flight tracking. Trigger words - flight, flying, UA, AA, DL, landing, arriving, departure.
development
Query real-time locations of people sharing via Find My. Look up where someone is, reverse geocode GPS coordinates, set up geofence alerts. Trigger words - findmy, find my, location, where is, geofence, track location.
tools
Access Figma designs via MCP or Chrome. Use when asked about Figma files, design mockups, wireframes, or UI designs. Trigger words - figma, design, mockup, wireframe, UI design, FigJam.