A Discord music bot that plays tracks from Spotify URLs, YouTube links, and plain-text search. Built for environments where YouTube aggressively blocks automated access: Deno-backed yt-dlp extraction, browser cookie support, a local audio library for offline fallback, and optional LLM-assisted track matching.
- Features
- Requirements
- Quick start
- Configuration
- Usage
- Project structure
- Data explorer
- Local library
- Testing and CI
- Radio modes
- Troubleshooting
- License
- Multi-source playback — Spotify tracks, albums, and playlists; YouTube and YouTube Music URLs; free-text search.
- Resilient YouTube access — Deno + EJS challenge solving, cookie hot-reload, cookieless client fallback, and rate-limit detection with user notification.
- Smart matching — Heuristic scoring with optional Claude tie-breaking when top candidates are close; search result caching to reduce API spend.
- Local library — Auto-caches played audio to disk; stable track IDs (
spotify_id→yt_{video_id}→ SHA-256 fallback); offline radio when YouTube is blocked. - 24/7 radio — Mood-based genre seeds, Spotify taste profiles, liked-track priority, DJ announcer (Edge-TTS), and fast-start playback (first track plays while the queue keeps filling).
- Data explorer — Lightweight web UI to inspect cached metadata, library usage, and run deduplication.
| Component | Version / notes |
|---|---|
| Python | 3.12+ |
| FFmpeg | Required for voice streaming |
| Deno | Bundled in Docker image; required locally for modern YouTube |
| Docker | Recommended for production |
| Discord bot token | Discord Developer Portal |
| Spotify app | Spotify Developer Dashboard |
| YouTube cookies | Export to cookies.txt via Get cookies.txt LOCALLY |
| Anthropic API key | Optional; enables LLM tie-breaking |
# One-time setup
./setup.sh
cp .env.example .env # fill in credentials
# Start bot + data explorer
docker compose up -d| Service | Port | Purpose |
|---|---|---|
bot |
8888 | Discord bot + Spotify OAuth callback |
explorer |
8080 | Data explorer UI |
Open the explorer at http://localhost:8080/web/explorer.html.
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # configure credentials
export BOT_TOKEN="..."
./run.shServe the explorer without Docker:
./serve_explorer.sh- Create an application and bot in the Discord Developer Portal.
- Enable Message Content and Voice States intents.
- Invite the bot with
botscope and permissions: Send Messages, Connect, Speak, Use Voice Activity. - Set your text and voice channel IDs in
src/config.py(BOT_TEXT_CHANNEL_ID,BOT_VOICE_CHANNEL_ID).
- Create an app in the Spotify Developer Dashboard.
- Add redirect URI:
http://localhost:8888/callback - Copy client ID and secret into
.env. - Run
!authin Discord after the bot starts.
The bot watches cookies.txt and reloads it when the file changes — no cron job required.
# When cookies expire or the bot alerts you
./refresh_cookies.sh chromeThe running container picks up the new file without a restart. Use --restart only if you want to force-recreate the container.
Copy .env.example to .env. Key variables:
# Required
BOT_TOKEN=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
# YouTube
YTDL_COOKIES_FILE=./cookies.txt
YTDL_SEARCH_DELAY_SEC=7.0 # pacing between searches (refill)
YTDL_SEARCH_DELAY_URGENT_SEC=0.5 # first-track / user-request fast path
YTDL_SEARCH_CONCURRENCY=1
# Optional
ANTHROPIC_API_KEY= # LLM tie-breaking
NORMALIZE_AUDIO=true # FFmpeg loudness normalization
DJ_ANNOUNCER_ENABLED=true
LIBRARY_ENABLED=true
LIBRARY_AUTO_DOWNLOAD=trueDocker Compose overrides several values for the container environment; see docker-compose.yml.
LLM calls are limited by design:
- Search cache reuses identical query results.
- LLM runs only when the top two heuristic scores are within 4.5 points.
- Album and playlist imports use LLM on the first three tracks only; the rest use heuristics.
Commands are prefixed with ! and must be sent in the configured text channel.
| Command | Description |
|---|---|
!play <query|url> |
Play a track, album, playlist, or YouTube URL |
!search <query> |
Show up to five candidates to choose from |
!playlist <url> |
Queue a Spotify or YouTube playlist |
!pause / !resume |
Pause or resume playback |
!skip |
Skip the current track |
!stop |
Stop and clear the queue |
!queue |
Show the queue |
!np |
Now playing |
!shuffle |
Shuffle the queue |
!move <from> <to> |
Reorder a queue item |
!remove <pos> |
Remove a queue item |
!priority <pos> |
Move a track to the front |
!leave |
Disconnect from voice |
| Command | Description |
|---|---|
!radio / !radio on |
Start 24/7 radio |
!radio off |
Stop radio and clear auto-queued tracks |
!radio profile admin |
Seed from admin Spotify taste (!auth required) |
!radio profile voice |
Blend tastes of linked users in the voice channel |
!radio profile playlist <url> |
Rotate tracks from a playlist |
!radio profile off |
Fall back to guild play history |
!mood <name> |
Set genre mood for recommendations |
| Command | Description |
|---|---|
!library |
Library stats and top plays |
!library search <query> |
Search cached tracks |
!likes |
Your liked tracks in this server |
Use the player embed buttons for like, radio toggle, and mood.
| Command | Description |
|---|---|
!auth |
Spotify OAuth for radio profiles and recommendations |
!cookies |
YouTube cookie health summary |
!spotify link |
Link your Spotify account for voice-mode radio |
| Input | !play |
!search |
|---|---|---|
| YouTube track / mix / playlist | Plays or queues | Plays or queues |
| Spotify track | YouTube match + play | Five candidates |
| Spotify album / playlist | Queues all tracks | Queues all tracks |
| Unsupported URL | Error | Error |
| Plain text | YouTube search + play | Five candidates |
src/
├── commands.py # Discord command handlers
├── playback.py # Queue, voice client, player embed
├── youtube.py # Search, extraction, rate limiting
├── spotify.py # URL parsing, OAuth, recommendations
├── scoring.py # Heuristic ranking + LLM tie-break
├── radio.py # Radio fill engine, moods, play history
├── library.py # Local cache, stable IDs, downloads
├── likes.py # Per-user likes and radio priority
├── spotify_taste.py # Taste profile builder
├── dj_announcer.py # Edge-TTS announcements
├── cookie_health.py # Cookie watchdog and admin alerts
└── config.py # Environment, yt-dlp, FFmpeg options
web/
├── explorer.html # Data explorer UI (Tailwind)
├── server.py # Static server + disk/dedupe API
└── dedupe_library.py # Deduplication engine
scripts/
└── dedupe_library.py # CLI wrapper for library cleanup
Entry point: bot.py → src/main.py.
A read/write UI for cache data on disk. Useful for debugging searches, monitoring disk usage, and cleaning duplicate library entries.
Endpoints
| Method | Path | Description |
|---|---|---|
GET |
/api/disk-usage |
Library size and per-file breakdown |
GET |
/api/library/dedupe-preview |
Duplicate groups and reclaimable space |
POST |
/api/library/dedupe |
Merge duplicates and delete extra files |
Tabs
| Tab | Source | Content |
|---|---|---|
| Búsquedas | youtube_metadata.json |
Cached search metadata |
| Biblioteca | library_index.json |
Tracks, play counts, file sizes |
| Likes | likes.json |
Per-user liked tracks |
Library table supports grouped view (one row per video_id) and detailed view (raw index entries).
Cache resolution checks .cache/ (inside Docker) and spotify_cache/ (host volume) and uses whichever contains data.
After successful playback, audio can be saved under .cache/library/ (host: spotify_cache/library/ when using Docker).
| File | Purpose |
|---|---|
library_index.json |
Metadata, play counts, file paths |
library/*.m4a |
Cached audio files |
likes.json |
Liked tracks (stable IDs) |
played_ids.json |
Radio deduplication history |
When YouTube rate-limits the bot, radio mode falls back to local tracks automatically.
Older builds used Python's hash() for some IDs, which changed on every restart and caused duplicate downloads. Current code uses stable IDs and skips re-download when a video_id is already on disk.
CLI
python3 scripts/dedupe_library.py # preview
docker compose stop bot
python3 scripts/dedupe_library.py --apply # apply
docker compose up -d botExplorer UI: Biblioteca → Tabla → Limpiar duplicados
The dedupe pass groups by video_id, keeps the highest-play-count entry, merges stats, updates likes.json and played_ids.json, and deletes orphan .m4a files.
The library now autonomously locates official artwork (Spotify album images preferred, complemented by Genius song art + Last.fm) + rich metadata (album, release date, genres, genius links).
- On play (if
LIBRARY_AUTO_ENRICH=true) or manually, entries getcover_url(Spotifyi.scdn.co/...or Geniussong_art_image_url),album, etc. stored inlibrary_index.json. - Local copies of covers (when
LIBRARY_FETCH_COVERS=true) saved tolibrary/covers/{tid}.jpg. - yt-dlp sidecar thumbnails + optional embedded tags when
LIBRARY_EMBED_METADATA=true. - Genius support via
GENIUS_ACCESS_TOKEN(get at https://genius.com/api-clients). Addsgenius_url,lyrics_state, high-quality artwork fallback. SetGENIUS_CLIENT_ID/GENIUS_CLIENT_SECRETtoo if using OAuth flows later.
CLI
python3 scripts/enrich_library.py # preview
python3 scripts/enrich_library.py --apply --max 50Discord
!library enrich (requires admin Spotify auth via !auth).
Explorer UI: Biblioteca tab → enriquecer button (preview + run).
Enrichment reuses existing Spotify auth, Last.fm fallback, and scoring. Safe, cached, and additive (never breaks playback or dedupe).
Also update features list mention if wanted, but ok.
## Testing and CI
Run tests locally:
```bash
BOT_TOKEN=test-token python3 -m unittest discover -s tests -p "test_*.py" -v
CI runs on every push and pull request via .github/workflows/ci.yml, installing requirements.txt and running the same test suite.
Radio fills the queue when it drops below a threshold. On cold start, the first resolved track begins playback immediately (early_play) while remaining slots are filled in the background.
Profile sources
- admin — liked songs, top tracks, and recently played from the authenticated admin account.
- voice — merged profiles from users in the voice channel who ran
!spotify link. - playlist — tracks from a configured Spotify playlist URL.
- off — guild play history and mood genres only.
After changing OAuth scopes, run !auth again. Voice mode requires each user to link their account with !spotify link.
| Symptom | What to do |
|---|---|
Sign in to confirm you're not a bot |
Run ./refresh_cookies.sh chrome on the host |
Stale cookies (!cookies) |
Same as above; check .cache/cookie_refresh.log |
Bot hangs with Enter the URL you were redirected to |
Spotify token expired — run !auth; the bot no longer blocks on stdin |
| Commands ignored | Verify BOT_TEXT_CHANNEL_ID in src/config.py |
| No audio | Confirm Connect + Speak permissions in the voice channel |
| YouTube search failures | Ensure Deno is installed (deno --version); refresh cookies |
| YouTube rate limit (~1 h) | Bot notifies in Discord; increase YTDL_SEARCH_DELAY_SEC; radio uses local library |
No se encontró nada |
Refine the query; if rate-limited, only cached/local tracks work |
| Empty local library | Play tracks normally first to seed the cache |
| Duplicate library entries | Run scripts/dedupe_library.py or use the explorer |
| Explorer shows no data | Ensure spotify_cache/ exists; run docker compose up -d explorer |
| Explorer dedupe fails after upgrade | docker compose up -d --force-recreate explorer |
| Docker build issues | docker compose build --no-cache bot |
| Spotify 403 on artist endpoints | API restriction for newer apps; radio degrades gracefully |
| Slow radio start | Expected on first YouTube search; cached/local tracks start in seconds |
MIT — use and modify freely.