A self-hosted Discord music bot built for music communities that care about getting the right track.
Stream from YouTube · Last.fm curation · Live controls
A self-hosted Discord music bot built with discord.py, yt-dlp, and aiosqlite. Designed to run well on a single-core, 1 GB RAM VPS (tested on both Oracle Cloud's Always Free AMD E2.1.Micro and Google Cloud's Always Free e2-micro, running Ubuntu) — or directly on an Android phone via Termux, no VPS required.
- Overview
- Features
- Requirements
- Installation
- Running as a systemd service
- Configuration
- Commands
- Project Structure
- Architecture Notes
- Contributing
- License
- Plays audio from YouTube and YouTube Music
!play/!playnextqueue yt-dlp's own top search result directly;!searchshows a list of candidates to pick from manually when the top hit isn't the one you want- Last.fm integration for
!vibesimilar-track curation and per-server!autoplay - Persistent queue snapshots survive restarts; per-server DJ role, prefix, 24/7 mode, and autoplay settings stored in SQLite
- Designed around the constraints of a 1/8-core shared VPS: single-threaded yt-dlp pool, 64 kbps Opus encoding, debounced panel refreshes, bounded deque-based queue
!playaccepts YouTube/YouTube Music URLs, playlist URLs, or plain text search queries — for a text query, the first yt-dlp search result is queued directly!playnextqueues a track immediately after the current one!searchshows up to 10 interactive results before committing — use this when!playpicks the wrong track- Vote-skip (
!skip): instant if you're the requester or a DJ; otherwise requires ≥50% of listeners to call it !forceskip— immediate skip, DJ-only!skipto <position>— jump to a queue position, dropping everything before it (DJ-only)!prev— requeue the last-played track!pause/!resume!stop— clears the queue and disconnects!loop— cycles through Off → Single track → Entire queue!repeat/!replay— aliases for one-track loop!seek <time>— jump to a position in the current track (1:30,90, or relative+30/-15); current requester or a DJ!volume [0-200]— show the current volume, or set it (DJ-only); applied via an ffmpeg filter so it stays on the low-CPU FFmpegOpusAudio path rather than switching to PCM!nowplaying— live now-playing embed with queue preview
!vibe <query> discovers similar tracks via Last.fm's track.getSimilar API. Results are sorted by match confidence (0.0–1.0). A curation panel lets you deselect tracks before queuing. When the queue drops to ≤10 tracks during an active vibe session, a refill prompt surfaces automatically offering more similar tracks.
Curation resolutions for a single guild run up to YTDLP_CURATION_CONCURRENCY at a time (own per-guild semaphore, separate from the playback path). Curation also has its own dedicated global semaphore, sized by the same YTDLP_CURATION_CONCURRENCY value — a large !vibe batch resolving in the background can no longer starve !play/!playnext/!search of the single global playback slot (YTDLP_CONCURRENT_EXTRACTS).
Each similar track from Last.fm is resolved to YouTube by taking yt-dlp's top search result for <artist> - <title> — no re-ranking.
Save and reload named curated playlists with !vibe-save / !vibe-load.
If autoplay is enabled for the server (!autoplay), the bot queues one similar track (via the same Last.fm pipeline) whenever the queue fully empties, using the last completed track as the seed — no !vibe required.
- YouTube watch URLs, short URLs (
youtu.be), and playlist URLs all resolve correctly - Playlist URLs respect
MAX_PLAYLIST_SIZE(default 25) - yt-dlp selects
bestaudio[ext=webm]→bestaudio[ext=m4a]→bestaudio→best[height<=480] - Stream URLs are cached per-track (128 entries, 30-minute TTL by default) and refreshed automatically 30s before the track ends
- Audio re-encodes through libopus at 64 kbps by default — copy mode is intentionally avoided to prevent pacing irregularities
- yt-dlp runs in a
ThreadPoolExecutor(max_workers=2)to avoid blocking the event loop - A global semaphore (
YTDLP_CONCURRENT_EXTRACTS, default 1) limits concurrent playback-path extractions on the constrained vCPU - Curation (
!vibe) resolves through its own separate global semaphore (sized byYTDLP_CURATION_CONCURRENCY), so a large curated playlist resolving in the background can't block ordinary playback commands - Per-guild playback semaphore (
Semaphore(1)) isolates guilds from each other - Curation resolutions use a separate per-guild semaphore sized by
YTDLP_CURATION_CONCURRENCY - Thread pool automatically recycles after 3 consecutive extraction timeouts
- Bounded yt-dlp socket timeout (
socket_timeout: 15) prevents stalled connections from permanently consuming a worker slot - Now-playing panel refresh is debounced (0.8s) with a state-key check to skip redundant Discord edits
- Queue duration tracked as a running total (
O(1)) rather than summing on every render
- Python 3.11+
- FFmpeg on
PATH - Discord bot token
- Last.fm API key (optional — required for
!vibecuration and the per-server!autoplaytoggle only)
Deploying to a fresh Ubuntu VPS? Clone the repo to the server, then run the setup script for your host — it installs everything, walks you through getting a Discord token and (optionally) a Last.fm key with live validation, and starts the bot as a systemd service in one go. All three scripts share the same installer under the hood (deploy/_common.sh); they only differ in a couple of host-specific checks and reminders.
git clone https://github.com/Pylxyr/PyxeeBot.git ~/musicbot
cd ~/musicbot| Host | Script | What it adds on top of the shared installer |
|---|---|---|
| Oracle Cloud | bash deploy/setup_oracle.sh |
Reports whether you're on the AMD (E2.1.Micro) or ARM (Ampere A1) Always Free shape; notes Oracle's ~10 TB/month egress allowance |
| Google Cloud | bash deploy/setup_gcp.sh |
Checks the VM's region against GCP's Always Free eligibility (us-west1/us-central1/us-east1) via the instance metadata server; warns about the 1 GB/month egress cap and the Network Service Tier / boot disk type gotchas that void the free tier |
| Anything else (DigitalOcean, Hetzner, AWS, bare metal, etc.) | bash deploy/setup.sh |
Nothing extra — just the shared installer |
APP_DIR defaults to wherever you actually cloned the repo (not a hardcoded path), so it doesn't matter what you name the folder or where it lives — cd into it and run the matching script. All three also add a 1 GB swap file automatically on any host with ≤2 GB RAM, since a single yt-dlp/ffmpeg burst can otherwise pressure a 1 GB box hard enough to risk an OOM-killed SSH session.
Running on your phone instead of a VPS? deploy/setup_termux.sh is a separate, self-contained installer for Termux — Android has no systemd, no apt/sudo, and building the voice stack (PyNaCl, davey) from source needs a couple of Rust-toolchain workarounds a VPS never touches (correct CARGO_BUILD_TARGET for the device's architecture, and an ANDROID_API_LEVEL floor of 34, without which maturin fails outright). It walks through the same interactive wizard as the VPS scripts — Discord token and an optional Last.fm key, both live-validated — then gets the bot running immediately, via termux-services if available, falling back to a detached tmux session otherwise.
pkg install git
git clone https://github.com/Pylxyr/PyxeeBot.git ~/musicbot
cd ~/musicbot
bash deploy/setup_termux.shUse Termux from F-Droid, not the Play Store — that build has been unmaintained since 2021. Two optional companion apps (also F-Droid) round it out: Termux:Boot starts Termux — and the bot with it, if you set up the termux-services option — automatically when your phone reboots, and Termux:API enables termux-wake-lock so Android doesn't kill the session in the background.
The steps below are for local development or platforms other than the automated script above.
1. Clone
git clone https://github.com/Pylxyr/PyxeeBot.git
cd PyxeeBot2. Create a virtual environment
python3 -m venv .venv
source .venv/bin/activate3. Install dependencies
pip install -r requirements.txt4. Configure
Copy deploy/.env.example to .env in the project root and fill in your token:
cp deploy/.env.example .envDISCORD_TOKEN=your_discord_bot_token
# Optional
LASTFM_API_KEY=your_lastfm_api_key
DEFAULT_PREFIX=!5. Run
python bot.pyIf you used one of the VPS scripts (
setup.sh/setup_oracle.sh/setup_gcp.sh), this is already done — the bot is running as a systemd service. The steps below are for setting it up manually. Termux has no systemd;setup_termux.shsets up the closest equivalent (termux-services) instead — see Automated Termux (Android) setup.
Create /etc/systemd/system/musicbot.service:
[Unit]
Description=Discord MusicBot
After=network.target
StartLimitIntervalSec=120
StartLimitBurst=5
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/musicbot
Environment="PATH=/home/ubuntu/musicbot/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
Environment="PYTHONMALLOC=malloc"
Environment="MALLOC_TRIM_THRESHOLD_=65536"
EnvironmentFile=/home/ubuntu/musicbot/.env
ExecStart=/home/ubuntu/musicbot/.venv/bin/python bot.py
Nice=-10
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
SyslogIdentifier=musicbot
MemoryHigh=600M
MemoryMax=700M
OOMScoreAdjust=-500
LimitNOFILE=65536
ProtectSystem=full
PrivateTmp=yes
NoNewPrivileges=yes
ProtectHome=read-only
ReadWritePaths=/home/ubuntu/musicbot/data /home/ubuntu/musicbot/logs
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable musicbot
sudo systemctl start musicbot
journalctl -u musicbot -f -o catA note on MemoryDenyWriteExecute=yes: you'll notice it's absent from the hardening directives above, even though it's normally a reasonable default. yt-dlp needs an external JS runtime (Deno by default — see YTDLP_JS_RUNTIME_PATH below and the Deno install step in deploy/_common.sh) to fully support YouTube, this bot's primary source. Deno is V8-based, same as Node, and needs writable+executable memory for its JIT compiler — which is exactly what this directive blocks. Tested directly: Deno panics immediately, on a trivial one-line script, under this restriction. Enabling it would break YouTube playback outright, not just as a narrow edge case, so it's deliberately left out.
All settings are read from .env. Every value has a default. See deploy/.env.example for the full annotated list.
| Variable | Default | Description |
|---|---|---|
DISCORD_TOKEN |
required | Bot token |
LASTFM_API_KEY |
— | Enables !vibe curation and the per-server !autoplay toggle |
DEFAULT_PREFIX |
! |
Global command prefix (per-server overrides via !setprefix) |
BOT_OWNERS |
— | Comma-separated owner user IDs (owner-only commands; app owner is always included) |
LOG_LEVEL |
INFO |
DEBUG / INFO / WARNING / ERROR |
LOG_TO_FILE |
true |
Write logs to LOG_DIR (rotated weekly by deploy/musicbot-logrotate, not in-process) |
LOG_DIR |
logs |
Log file directory |
MAX_QUEUE_SIZE |
100 |
Maximum queue length per guild |
MAX_QUEUE_SIZE_PER_USER |
0 |
Per-user track limit; 0 disables the limit |
MAX_PLAYLIST_SIZE |
25 |
Maximum tracks loaded from a single playlist URL |
IDLE_TIMEOUT_SECONDS |
180 |
Disconnect after this many seconds idle (no tracks, no listeners) |
EMPTY_CHANNEL_TIMEOUT_SECONDS |
60 |
Disconnect after this many seconds alone in a voice channel |
YTDLP_CONCURRENT_EXTRACTS |
1 |
Global yt-dlp extraction concurrency limit |
YTDLP_PREFETCH_COUNT |
1 |
Tracks to pre-resolve ahead of the current position |
YTDLP_CURATION_CONCURRENCY |
3 |
Concurrent per-guild resolutions during !vibe / !vibe-load |
YTDLP_SEARCH_RESULTS |
5 |
Raw candidates fetched per search as a safety margin against malformed entries; the first valid one is used |
YTDLP_RESOLVE_CACHE_SIZE |
128 |
Maximum cached stream URL entries |
YTDLP_RESOLVE_CACHE_TTL_SECONDS |
1800 |
Stream URL cache TTL (30 min) |
YTDLP_EXTRACT_TIMEOUT_SECONDS |
45 |
Per-extraction timeout |
YTDLP_SOCKET_TIMEOUT |
15 |
yt-dlp socket timeout |
NEAR_END_PREFETCH_SECONDS |
30 |
Trigger stream URL refresh this many seconds before track end |
YTDLP_COOKIES_FILE |
— | Path to Netscape cookies file |
YTDLP_JS_RUNTIME_PATH |
— | Path to a JS runtime binary, for sites requiring JS signature decryption. If unset, yt-dlp auto-detects a deno binary on PATH — the setup wizards install Deno system-wide for exactly this. Only set this to pin a different runtime/path (e.g. Node) instead |
OPUS_BITRATE_KBPS |
64 |
Opus encoding bitrate (64–256) |
NP_AUTO_REFRESH |
false |
Auto-refresh the now-playing panel on a timer |
NP_AUTO_REFRESH_INTERVAL |
30 |
Auto-refresh interval in seconds |
ERROR_ANNOUNCE |
true |
Post playback errors to the announce channel |
RESTORE_QUEUE_ON_RESTART |
true |
Restore queue from snapshot after bot restart |
BOT_ACTIVITY_URL |
pylxyr.github.io/PyxeeBot-Page/ |
Text shown in the bot's Discord status ("Watching …") |
| Command | Aliases | Description |
|---|---|---|
!join |
summon |
Join your voice channel |
!leave |
disconnect |
Leave the voice channel |
!play <query> |
p |
Queue a URL, playlist, or search query |
!playnext <query> |
pn |
Queue a track immediately after the current one (DJ-only) |
!pause |
— | Pause playback |
!resume |
— | Resume playback |
!skip |
next |
Vote-skip (instant if you're the requester or a DJ; requires ≥50% of listeners otherwise) |
!forceskip |
fs |
Immediate skip, DJ-only |
!skipto <position> |
— | Jump to a queue position, dropping everything before it (DJ-only) |
!prev |
previous, back |
Requeue the last-played track |
!stop |
— | Clear the queue and disconnect |
!loop |
— | Cycle loop mode: Off → Single track → Entire queue (DJ-only) |
!repeat |
rp |
Toggle single-track loop on/off for the current track |
!replay |
— | Re-queue the current track to play immediately next (DJ-only) |
!seek <time> |
— | Jump to a position in the current track — 1:30, 90, or relative +30/-15 (requester or DJ) |
!volume [0-200] |
vol |
Show the current volume, or set it (DJ-only) |
!nowplaying |
np |
Show the now-playing embed |
| Command | Aliases | Description |
|---|---|---|
!queue |
q |
Show the current queue |
!clear |
— | Clear the entire queue (DJ-only) |
!shuffle |
— | Shuffle the queue (DJ-only) |
!move <from> <to> |
— | Move a track to a different queue position (DJ-only) |
!remove <position> |
— | Remove a track (requester or DJ) |
!history |
— | Show recently played tracks (session only) |
!toptracks |
top |
Show the all-time most-played tracks for this server |
!toprequestors |
topreqs |
Show the all-time top track requestors for this server |
| Command | Aliases | Description |
|---|---|---|
!search <query> |
find, s |
Browse up to 10 interactive results before queuing |
| Command | Aliases | Description |
|---|---|---|
!playlist save <name> |
— | Save the current queue as a named server playlist |
!playlist load <name> |
— | Load a saved playlist into the queue |
!playlist list |
— | List saved playlists for this server |
!playlist show <name> |
— | Preview the tracks in a saved playlist |
!playlist delete <name> |
— | Delete a saved playlist |
| Command | Aliases | Description |
|---|---|---|
!vibe <query> |
vb |
Discover similar tracks via Last.fm and queue them interactively. Cooldown: 1 use / 15s per guild |
!vibe-save <name> |
vsave |
Save the current vibe session's tracks as a named playlist |
!vibe-load <name> |
vload |
Load and re-queue a saved vibe playlist |
| Command | Aliases | Description |
|---|---|---|
!setprefix <prefix> |
— | Change the command prefix for this server (Manage Server) |
!setdj <role> |
— | Set the DJ role (Manage Server) |
!cleardj |
— | Remove the DJ role (Manage Server) |
!dj |
— | Show the current DJ role |
!stay |
— | Toggle 24/7 mode — bot stays connected when the queue empties (Manage Server) |
!autoplay |
— | Toggle per-server autoplay — queues a similar track when the queue empties (Manage Server) |
!stats |
— | Show bot process stats: versions, guild count, voice connections, RSS, latency (owner only) |
!ping |
— | Check gateway latency |
!commands |
cmds |
Open the command help menu |
Scan report · 2026-09-16
- ✓ Prohibited terms or links
- ✓ Repository eligibility
- ✓ slopscore.md paperwork
- ✓ Content policy
- ✓ Risk review
From the balcony · 2 of 4 clapped
- Princessclapped
Has MIT license, declared status 'works-on-my-machine', clear installation instructions for multiple platforms, and demonstrates actual functionality with specific features and architecture.
- Crusoeclapped
No vulnerable dependencies, self-hosted with local SQLite storage, no credential requests, and clear data handling story.
Cap'm Slop and Schnitzel read it and passed. Their reasons are on the balcony, with every other verdict.
Critics are accounts on this site with no GitHub account behind them. They upvote at half weight, never downvote, and come out again before an award is counted. Who they are.
0 comments
log in to comment.