Shen — a Discord music bot that doubles as a YouTube website

2026-09visit
discord.jsNode.jsExpressJavaScript
Shen — a Discord music bot that doubles as a YouTube website

Shen is a personal Discord music bot with a full website bolted on. You search YouTube or YouTube Music from the site, hit play, and the track streams straight in your browser — or you add it to the queue and the bot plays it in your voice channel.

It is one Node project that does two jobs: an Express API that serves the site and streams audio, and a discord.js client that joins voice channels and plays tracks. The same queue powers both surfaces, so the website and the bot never get out of sync.

Two surfaces, one queue

The interesting part is the shared state. Search results from the web can be sent to the Discord queue, tracks played in Discord appear in the site's live "now playing" state, and a pending queue holds tracks until someone runs /join. Updates flow to the website over SSE (/api/events), so the player bar ticks live without polling.

architecture

Streaming that keeps up with YouTube

Audio extraction relies on yt-dlp (shipped as the bundled binary from @distube/yt-dlp) with play-dlas a fallback — YouTube's player/signing changes often, so two extraction paths give a decent buffer. ffmpeg-static supplies ffmpeg so any audio format can be transcoded: Opus for the Discord stream and MP3 for the browser player.

streaming flow
YouTube URL
↓ yt-dlp (primary) / play-dl (fallback)
direct audio URL
↓ ffmpeg
Discord: Opus 48kHz stream Web: MP3 in <audio>

Full feature list

  • Website player — search, stream in-browser, autocomplete, playlists expand to their first 20 tracks.
  • Discord control — /join, /leave, /play, /add, /queue, /skip, /pause, /resume, /loop, /stop, /save.
  • Cross-surface queue — web "Pending queue" runs automatically when the bot joins, auto-advance through search results / pending / library.
  • Live status — SSE keeps the player bar and queue snapshot ticking in real time.
  • Library & history — JSON persistence (data/library.json, data/history.json).
  • Discord OAuth + auto-join — signed-in visitors can have the bot join their own voice channel from the phone-friendly site.
  • Lyrics — Genius-backed lyrics route on the website.
  • Theme engine — the React site ships with several player themes.
  • Cookie consent — a first-run banner links to the project's cookie policy before the site sets its session cookie.
  • Grid-reveal login background — the rare-ui “grid-reveal” canvas effect on the sign-in backdrop.

Why it was hard

The genuinely nasty part was YouTubedatacenter-IP blocking. On a cloud host like Render, YouTube frequently refuses or alters responses. The fix that works is routing extraction through a Cloudflare WARP proxy — running wireproxy so the bot tunnel exits from a residential-looking IP. Combined with the tv player client (tried before android/web) this dodges the sign-in bot wall most of the time.

There were also the classic single-process pitfalls: overnight crashes taking the whole service down (now caught by uncaughtException/unhandledRejection handlers), a double-enqueue bug where the web pending queue andthe bot both enqueued a track, and trusting client-supplied guild/channel IDs (fixed by deriving the play target from the caller's own Discord voice state).

How it actually works

It is one Node process. server.js brings up the Express app and, on the same tick, hands off to startBot() — so the Discord gateway and the HTTP API share one queue, one set of JSON files and one ffmpeg pipeline. Frontend and backend are separate deploys: a React 19 / Vite app on Vercel rewrites every /api/* call to the Node backend on Render.

request flow
Discord user ──▶ gateway (WS) ──▶ bot.js ──▶ control*() ─┐
▼
Browser ──▶ server.js (Express + SSE) ─────▶ queue.js (QueueManager)
│
▼
yt-dlp + ffmpeg (+ WARP)

Project map

cloudy/
server.js Express routes, SSE, Discord OAuth, process boot
bot.js discord.js client, voice, 14 slash commands
queue.js per-guild queues + web pending buffer + snapshot
store.js JSON persistence (atomic writes, per-file locks)
search.js search + URL / playlist resolution
ytdlp-util.js yt-dlp args, WARP probe, cookie handling
audio.js /api/audio (MP3) and /api/lyrics (captions)
genius.js optional Genius lyrics source
Dockerfile node:24-alpine + ffmpeg + wireproxy
docker-entrypoint.sh materialise the WARP profile, start wireproxy
web/ React + Vite website

Boot

The process installs uncaughtException and unhandledRejection handlers first — so one bad stream can never take the whole site down — then sets up the CORS allowlist, defines every route, serves the built site if it is present, starts the bot and finally listens. If DISCORD_TOKEN is missing the site still runs; only the bot stays offline.

The audio pipeline

A plain-text query is parsed straight out of YouTube's ytInitialData (with play-dl as a fallback); a URL or playlist goes through yt-dlp -J. For playback the audio crosses two more processes:

stream
yt-dlp (-f bestaudio) │ ffmpeg (-f s16le -ar 48000 -ac 2) │ @discordjs/voice
raw PCM ──────────┴──────────────▶ Opus (opusscript) ──▶ Discord
same feed ─▶ ffmpeg MP3 ─▶ browser <audio>

One deliberate quirk: the Discord stream is fed as raw PCM and encoded to Opus in-process with opusscript, because declaring yt-dlp's opus/ogg container as Opus produced silence. @discordjs/voiceships no encoder of its own, so that dependency is load-bearing — this is the bug that caused the “no audio” reports.

One queue, two surfaces

queue.jskeeps one queue per guild plus a single “active” guild. Tracks queued from the website while the bot is idle land in a pending buffer and flush automatically on the next /join or play — never both, which is what fixed the old double-enqueue bug. Every mutation emits a change event that server.js rebroadcasts over SSE (/api/events), so the player bar stays live without polling.

API & access control

Read routes are open, but every mutating route needs a Discord session, and the live voice controls (/api/control/*) derive the target guild and channel from the caller's own voice state — a client cannot point the bot at an arbitrary server. Login is Discord OAuth only (the identify scope), with an in-memory session store and a state cookie guarding the callback.

State on disk

There is no database. The queue, library, history, playlists, favorites, the web “now playing” marker and per-visitor play-time all live as JSON under data/. Writes go through store.js, which does an atomic temp-file-plus-rename and serialises writers per file, so a crash mid-write can't truncate a file — and two concurrent saves can't clobber each other.

Deploy & operations

The backend ships as a Docker image — node:24-alpine plus ffmpeg and wireproxy — on Render. The site is a Vercel deploy that proxies /api/* to it. Because there is no unit-test suite, CI is the safety net: it syntax-checks every module, boots the server without a token and hits /health, verifies the web lockfile still contains Vite, and fails if a WARP private key is ever committed.

environment
DISCORD_TOKEN bot token (absent -> site runs, bot stays offline)
CLIENT_ID Discord app id (also used for command registration)
CLIENT_SECRET required for web login
FRONTEND_URL site origin - OAuth redirect + CORS allowlist
REDIRECT_URI must match the Discord OAuth2 redirect
WARP_* WARP key/address, or socks5 endpoint + probe TTL
YTDLP_COOKIES* Netscape / Cookie-Editor export for yt-dlp
GENIUS_TOKEN optional lyrics source

Known limits

One active guild at a time; a single shared queue with no multi-user split. Sessions are in-memory, so a restart logs everyone out and the service is single-instance only. There is no in-browser player UI yet (the /api/audio endpoint is ready though). And YouTube markup drift is the recurring cost of this whole approach — the search and playlist parsers are one YouTube change away from needing a patch.

Privacy & legal

The project has its own standing pages for the boring-but-important bits: see the Terms of Service, Cookie Policy, and Privacy Policy.

Try it

The site is live at shen.zagan.space with the backend on Render. Source: the shen repository.