Spotify (read-only library + crate export)
Pull liked songs, playlists, and top tracks out of Spotify, and export them in the shared
crate format that beatport-skill matches against.
Read-only by design. The requested scopes cover reading the library only — no playlist-modify, no playback control. Don't add write scopes without asking.
The one thing to know: no BPM or key
Spotify permanently disabled audio-features, audio-analysis, recommendations,
related-artists, and 30-second previews for any app created after 2024-11-27. A new
app gets 403 on those endpoints — this is policy, not a bug, and no amount of scope
fixes it.
So a crate exported here has bpm: null and key: null by design. BPM and key come
from Beatport during beatport_skill.py match. If Idan asks for the BPM of something he
liked, that is the path — don't go looking for a Spotify endpoint that will serve it.
What Spotify does still give, and what makes this whole workflow work: ISRC, on
track.external_ids.isrc. That is an exact identifier, and matching a crate to Beatport by
ISRC is exact rather than fuzzy. Always prefer a Spotify-sourced crate over a YouTube one.
Setup
Two ways in. (b) needs no app at all — use it if the developer dashboard refuses to create one, which it does fairly often with a generic "Something went wrong".
(a) CSV import — the one that works right now
Spotify froze new developer-app creation in Dec 2025 and it is still frozen. The dashboard shows a Create app button that fails with a generic "Something went wrong, we were not able to create your app", or says outright "New integrations are currently on hold". Nothing about the form is wrong and retrying will not help.
Route around it with an existing exporter — those authorize against apps registered long before the freeze, so they still work:
- https://exportify.net (open source, browser-only, read-only) - log in, export the playlist or Liked Songs to CSV.
- Import it:
python3 ~/.claude/skills/spotify-skill/spotify_skill.py import-csv ~/Downloads/friday.csv --name friday
Exportify's CSV carries ISRC, which is the field that makes Beatport matching exact —
so this path loses nothing that matters. The importer maps columns by name and handles
other exporters too (semicolon-delimited, mm:ss durations, missing ISRC); it reports
which columns it used and the ISRC coverage.
(b) Pasted web-player token — works, but off the sanctioned path
⚠️ Tell Idan the tradeoff before he does this. Spotify's own API response calls usage of the web player's token endpoint "not permitted under the Spotify Developer Terms and Developer Policy". Reusing your own session token is his call on his own account, but it is not a sanctioned integration path — prefer (a).
Note the web player no longer calls api.spotify.com much, so filtering on that shows an
empty Network tab. Filter on spotify.com and look for api-partner.spotify.com or
spclient.spotify.com rows instead, then copy the Authorization header after Bearer :
python3 ~/.claude/skills/spotify-skill/spotify_skill.py setup --token 'BQ...'
The web player's own token already carries the library scopes, so liked songs and playlists work immediately. It expires after roughly an hour and there is no refresh — on a 401 the skill says exactly how to re-copy it. Good for a one-off crate export; annoying if you're going to do this weekly.
(c) Registered app + PKCE — best, if the freeze ever lifts
- Create an app at https://developer.spotify.com/dashboard.
- Add the redirect URI
http://127.0.0.1:8899/callback. It must be the literal127.0.0.1. Spotify explicitly rejectslocalhost; loopback is the one case where plainhttpis allowed. Getting this wrong is the #1 setup failure. - Copy the Client ID (PKCE is used, so no client secret is needed — don't ask for one).
python3 ~/.claude/skills/spotify-skill/spotify_skill.py setup --client-id YOUR_CLIENT_ID
python3 ~/.claude/skills/spotify-skill/spotify_skill.py login
If "Create app" fails with "Something went wrong, we were not able to create your
app": it is a long-standing dashboard bug, not something wrong with the form. Reported
causes are an ad-blocker eating the POST, the loopback http:// redirect URI tripping
dashboard-side validation, and Web API access wanting a Premium account. Try incognito
with extensions off; or create the app with a placeholder https://example.org/callback
and add the 127.0.0.1 URI afterwards in Edit Settings, which is a different validator.
If it still refuses, use (a) — it needs no app and gets the same data.
login opens a browser and runs Authorization Code + PKCE against a one-shot local
callback server. The refresh token is stored in tokens/ (gitignored, chmod 600) and
refreshed automatically, so login is a one-time step.
Commands
S=~/.claude/skills/spotify-skill/spotify_skill.py
python3 $S me
python3 $S liked [--limit 50] [--all]
python3 $S playlists [--all]
python3 $S playlist PLAYLIST_ID_OR_URL [--all] # accepts id, URL, or spotify:playlist: URI
python3 $S top-tracks [--range short|medium|long]
python3 $S recently-played [--limit 50]
python3 $S search "query" [--limit 20]
python3 $S logout
--all follows pagination to the end; without it you get one page. For a real set, use
--all — a 300-track playlist silently truncated to 50 is a bad way to find out.
Export a crate
python3 $S export-crate --name friday --liked --all
python3 $S export-crate --name friday --playlist "https://open.spotify.com/playlist/37i9..." --all
python3 $S export-crate --name friday --top
Writes ~/.claude/skills/beatport-skill/crates/<name>.json and reports with_isrc — the
count of tracks carrying an ISRC, i.e. how many will match Beatport exactly. Then:
python3 ~/.claude/skills/beatport-skill/beatport_skill.py match ~/.claude/skills/beatport-skill/crates/friday.json
See beatport-skill for the rest of the pipeline and the crate schema.
Output
All commands print JSON. Track objects are already in crate shape (source, source_id,
artist, title, mix, isrc, duration_ms, url), so any listing can be fed onward
without reshaping.
mix is parsed out of the track title when Spotify has it in parentheses — e.g. "Grey
(Tale Of Us Remix)" yields mix: "Tale Of Us Remix". That field is what stops Beatport
matching from picking the wrong remix, so don't strip it.
Notes
- Standard library only — no pip installs, no spotipy.
config.jsonandtokens/are gitignored.- Label is not available. Spotify's track object doesn't carry it; the crate leaves
label: nulland Beatport fills it in duringmatch. - On
429, the skill stops and says it was rate limited. Wait, don't retry in a loop. - Podcast episodes in a playlist are skipped, not half-parsed as tracks.