Building a playlist takes real effort. For a DJ, the work is curation — picking the right tracks from a list, in the right order. spotify-mixmaster does it automatically from your Spotify Liked Songs, by genre and mood.
A beam search assembles the tracklist from harmonic mixing (Camelot wheel), BPM flow, and an energy curve.
Use it as a CLI, or hook it up to hermes-agent and ask in natural language.
🎧 Example: chill jazz house
— 28 tracks · 1 hr 33 min, an actual set built with resequence
6. [ 5A 124.0 E0.92 2022] Work It Out Trimtone disco house 5A→5A same(1.00) Δbpm 0.0%
7. [ 5A 126.0 E0.83 2023] Sambossa Bellaire disco house 5A→5A same(1.00) Δbpm 1.6%
★8. [ 6A 129.8 E0.95 2007] Girls Like Us B15 Project bassline 5A→6A up1(0.90) Δbpm 3.0%
- Harmonic mixing — directional Camelot wheel scoring (same / up1 / relative / boost …)
- BPM flow — ±6% transitions with half/double-time matching (87 ≈ 174)
- Energy curves — classic (late peak) / linear / wave / flat, or define your own in YAML
- Genre buckets + DJ slots — 40-ish Beatport-style buckets, opener-to-closer slot classification
- Draft workflow — drop a few favorite tracks into a playlist and the sequencer places them as landmarks, filling the gaps around them
- Quality floor — refuses to force bad transitions; ends the set short rather than padding it
- Reproducible — every run records its seed; same seed = same set
- All rules (weights, constraints, curves, buckets) live in
config/*.yaml— tune without touching code
| API | Purpose | Free tier |
|---|---|---|
| Spotify Web API | Liked Songs sync, playlist creation (OAuth PKCE) | ~600 req/day in Dev Mode (measured) |
| FreqBlog | BPM · Camelot key · energy · mood (primary enrichment) | 1,000 req/month, ~50 on-demand analyses/day |
| Last.fm | Artist tags → genre classification | generous |
| MusicBrainz | Artist country (--country filter) |
1 req/s, no key |
| Musicae (Apify actor) — optional | Per-track genres, 9 DJ scores | ~1,000 tracks/month on Apify credits |
Why third-party APIs? Spotify blocked Audio Features / Audio Analysis / Recommendations for new apps on 2024-11-27, with no replacement. The Feb 2026 changes also removed batch artist lookups and effectively killed the
genresfield — so BPM/key/energy come from FreqBlog and genres from Last.fm tags. Details in docs/api-responses.md.
- uv + Python 3.12+
- A Spotify account and an app Client ID from the developer dashboard (setup below)
- A FreqBlog API key — BPM/key/energy enrichment
- A Last.fm API key — artist tags
- (Optional) An Apify account — Musicae per-track genre / DJ-score batch analysis
git clone https://github.com/gnujoow/spotify-mixmaster.git
cd spotify-mixmaster
uv sync1. Register a Spotify app — https://developer.spotify.com/dashboard → Create app:
- Redirect URI:
http://127.0.0.1:8888/callback(localhostis rejected for new apps — it must be127.0.0.1) - If asked which APIs you need, check Web API
- Copy the Client ID (PKCE flow — no client secret needed)
2. Config file
cp config.example.toml config.tomlFill in your Client ID and API keys:
[spotify]
client_id = "..."
[enrichment]
freqblog_api_key = "..." # BPM/key/energy enrichment
lastfm_api_key = "..." # artist tags (https://www.last.fm/api/account/create)
# musicae_api_key = "..." # optionaluv run mixmaster auth # 1. Spotify login (browser, once)
uv run mixmaster sync # 2. pull Liked Songs + artist tags
uv run mixmaster enrich --limit 30 # 3. BPM/key/energy enrichment (daily drip)
uv run mixmaster classify # 4. genre buckets + DJ slots
uv run mixmaster stats # check progress- Every command is resumable — interrupt it, run it again, it picks up where it left off (idempotent upserts, graceful stop on 429)
enrich --limit 30matches the FreqBlog free tier (1,000/month). Running without--limitcan burn a month of quota in a daysyncis incremental; usesync --fullto also reflect un-liked tracks
# 60 minutes of deep house, warmup→peak→cooldown curve, compare 3 candidates
uv run mixmaster sequence --minutes 60 --curve classic --candidates 3 \
--bucket deep_house --bucket organic_house_downtempo
# Recreate the candidate you liked by seed, publish as a private playlist
uv run mixmaster sequence --minutes 60 --curve classic \
--bucket deep_house --seed 549427 --create "Deep House 60"
# Draft workflow: tracks in this playlist are guaranteed in the set
uv run mixmaster sequence --minutes 60 --from-playlist <URL>
# Reorder an existing playlist by the sequencing rules (dry-run by default)
uv run mixmaster resequence <URL> [--apply | --create "name"]| Option | What it does |
|---|---|
--minutes N / --tracks N |
Set length (minutes or track count) |
--bucket X |
Genre bucket filter (repeat = OR, k+rnb = intersection) |
--slot X |
DJ slot filter (opener/warmup/peak/afterhours/closer) |
--curve X |
Energy curve (classic/linear/wave/flat) |
--country CC |
Artist country (e.g. KR) |
--candidates N |
Generate and compare N candidates |
--seed N |
Reproduce (same conditions + seed = same set) |
--seed-track ID |
Start from this track |
--require-track ID / --from-playlist REF |
Draft tracks, always included (marked ★) |
--create "name" |
Publish as a private playlist |
--json / --json-out PATH |
Structured output (for agent integrations) |
If you run hermes-agent, register the skill and trigger set generation in natural language:
- Skill package:
hermes/mixmaster-dj/SKILL.md - Full recipes (natural language → options, candidate critique loop,
ops monitoring):
docs/hermes-skill.md - Execution wrapper:
scripts/hermes-seq.sh— a start/poll two-command scheme that avoids agent terminal-tool timeouts and output truncation
PKCE means there is no client secret. Run mixmaster auth on a machine
with a browser, copy data/tokens.json to the server, and token refresh
works headless from then on.
⚠️ Never refresh the same token from two machines — refresh token rotation will invalidate one side. Operate from one place only.- Suggested cron: daily
sync→enrich --limit 30→stats(right after 00:00 UTC, when the FreqBlog daily quota resets) - The DB uses WAL mode — before copying it to another machine, run
sqlite3 data/mixmaster.db "PRAGMA wal_checkpoint(TRUNCATE)"
src/mixmaster/
├── cli.py # auth/sync/enrich/classify/import-musicae/sequence/resequence/stats
├── sync.py # collection: Liked Songs + Last.fm artist tags
├── enrich.py # enrichment state machine (drip backfill, quota handling)
├── enrichment/ # adapters: freqblog.py + musicae.py
├── classify.py # YAML rules → genre buckets / DJ slots
├── sequence.py # beam-search sequencing + report + publish + reorder
├── musicbrainz.py # artist country
├── lastfm.py # artist.getTopTags
├── db.py # SQLite (user_version migrations, WAL)
└── spotify/ # OAuth PKCE + API client (retry/quota handling)
config/sequence.yaml # sequencing rules — meant to be edited
config/classify.yaml # classification rules — meant to be edited
docs/phase4-design.md # sequencing design, research, decision log
uv run pytest # 145 tests