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.
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.
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.
Project map
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:
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.
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.
