Building a Self-Hosted Spotify Song Request Twitch Panel
/ 8 min read
Overview
I wanted song requests without spam, queue chaos, or vendor lock-in. Off-the-shelf bots felt clunky, so I built a system I control end to end. The app handles auth, request intake, moderation, queue approval, and Spotify token refresh. The result is small enough to understand, though complete enough to use on stream.
The scope grew from real problems. I started with one form that accepted a Spotify link. The first stream exposed the rest. Wrong URLs, duplicate requests, junk submissions, auth gaps, and token expiry all showed up fast. Each new layer came from one clear point of failure.
System overview
The system uses Discord OAuth for identity, PostgreSQL and Drizzle for storage, a role table for moderators and bans, intake tables for raw requests and approved songs, a Spotify token row for access and refresh state, API routes for queue actions, a small admin panel, an in-memory rate limiter, and a retry wrapper around Spotify calls.
Each part has a narrow job. That is the main design rule. A queue tool like this gets messy when one route or page owns too much state.
Discord OAuth and identity
Login starts with Discord. The callback stores a small JSON payload in an http-only discord_user cookie. The payload only needs the Discord id and username. That is enough for identity and for linking the user to a row in user_roles.
const cookieStore = cookies()cookieStore.set('discord_user', JSON.stringify({ id: user.id, username: user.username}), { httpOnly: true, path: '/'})Every protected route reads that cookie. If the cookie is missing, the request stops there. If the cookie exists, the app upserts a row in user_roles so usernames stay current and moderation flags stay in the database.
Role sync on access
I upsert the user on each interaction. That keeps usernames fresh and makes bans take effect without extra cleanup work.
await db.insert(userRoles).values({ id: user.id, username: user.username, isModerator: false, isBanned: false}).onConflictDoUpdate({ target: userRoles.id, set: { username: user.username }})Enforcement
Moderator-only routes check one flag and fail early.
if (!role || !role.isModerator) { return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })}Submission routes apply the same kind of early return for banned users.
Data model
This system does not need many tables. It needs clear ones.
spotify_tokens
This table stores the current access token, refresh token, and update time. I keep a single row and refresh through that row instead of forcing a full user login again.
export const spotifyTokens = pgTable('spotify_tokens', { id: text('id').primaryKey(), access_token: text('access_token').notNull(), refresh_token: text('refresh_token').notNull(), updated_at: timestamp('updated_at').notNull()})track_requests
This table stores raw, unapproved links. The request stays pending until a moderator reviews it.
export const trackRequests = pgTable('track_requests', { id: text('id').primaryKey().default(sql`gen_random_uuid()`), link: text('link').notNull(), requestedBy: text('requested_by').notNull(), status: text('status').default('pending'), createdAt: timestamp('created_at').defaultNow()})song_requests
This table stores approved tracks with normalized metadata so the UI does not need to keep hitting Spotify for common views.
export const songRequests = pgTable('song_requests', { id: text('id').primaryKey().default(sql`gen_random_uuid()`), spotifyUri: text('spotify_uri').notNull(), title: text('title').notNull(), artist: text('artist').notNull(), requestedBy: text('requested_by').notNull(), approved: boolean('approved').default(false), rejected: boolean('rejected').default(false), createdAt: timestamp('created_at').defaultNow()})user_roles
This table decides trust. It stores moderator state, ban state, and a username for display.
export const userRoles = pgTable('user_roles', { id: text('id').primaryKey(), username: text('username'), isModerator: boolean('is_moderator').default(false), isBanned: boolean('is_banned').default(false), createdAt: timestamp('created_at').defaultNow()})You can add more indexes later if volume rises. Early on, the clearer win is a simple shape you trust.
Roles and permission flow
The request path is fixed. Read the discord_user cookie. Upsert the user. Stop early if the user is banned. If the route is moderator-only, check the flag. Then continue. I did not need a policy engine. Two flags cover the real cases.
Admin panel
The admin view lives under the users area and requires moderator access. The server validates the user on page load. The client still defends the route, though the server remains the source of truth.
The panel lists id, username, moderator state, and banned state. Buttons send small PATCH requests with only the field that changed.
await fetch('/api/users/123456789/role', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isModerator: true })})Search and filter stay in the browser. That keeps the panel fast and avoids extra database work for simple moderation tasks.
Request lifecycle
When a viewer submits a Spotify link, the server parses and validates the link first. If the URL is malformed, the route returns a clear error. If the user is rate limited, the route returns a wait message. If both checks pass, the server writes a pending row to track_requests.
Moderators review pending rows in a separate view. On approval, the server looks up the track through Spotify, inserts a normalized row into song_requests, and can queue the track to the active playback device. On rejection, the row is marked rejected and kept for audit until cleanup.
If a Spotify call fails with a 401, the system refreshes the token and retries once. If the retry fails, the route records the error and skips the action instead of failing silently.
API layer
The public surface is small. GET /api/user returns the current user from the cookie. POST /api/spotify/submit accepts a request link and applies validation plus rate limiting. GET /api/spotify/requests returns pending or approved request data for moderators. PATCH /api/spotify/requests approves or rejects a request. GET /api/users and PATCH /api/users/:id/role back the moderation panel.
Each route validates early and returns a consistent JSON shape. That matters because the frontend should not need route-specific error handling for ordinary cases.
Moderation and safety layers
There are three main layers here. Input validation, request rate, and token health.
Input validation
The server rejects empty strings and non-Spotify links, then normalizes valid track URLs to one form so duplicate checks stay reliable.
Rate limiting
The first version uses an in-memory Map keyed by IP with a five-second minimum between submissions.
const rateLimitMap = new Map<string, number>()
function isRateLimited(ip: string) { const now = Date.now() const last = rateLimitMap.get(ip) || 0 if (now - last < 5000) return true rateLimitMap.set(ip, now) return false}This is enough for one deployment region. If the app grows beyond one instance, Redis is the next stop.
Ban enforcement
The ban check runs before link parsing. A banned user should not consume more work than needed.
Error messages
The routes return direct messages such as Invalid Spotify track link or Too many requests. Please wait. The UI can turn those into a toast or inline warning. Vague errors slow moderation and confuse users.
Spotify token strategy
Spotify access tokens expire, so every Spotify integration needs a refresh path. I wrap Spotify calls in a helper that refreshes once on 401 and then retries.
async function withSpotify(fn) { try { return await fn() } catch (err) { if (err.response?.status === 401) { const refreshed = await refreshAccessToken() if (!refreshed) return { error: 'Token refresh failed' } return await fn() } throw err }}The app stores the new token and timestamp after refresh. More advanced pre-refresh logic can come later if the failure rate warrants it.
Frontend and UX notes
The app uses Next App Router and Tailwind. I kept the interface plain on purpose. Fast forms, clear approval states, and simple moderation controls matter more than animation in a queue tool. The frontend should call APIs and render state. Queue rules belong on the server.
Problems I ran into
The first class of issues came from environment differences. Cookie behavior changed between local and deployed environments until I made the cookie path explicit and stuck to the normal http-only flow. The second class came from Spotify token expiry in the middle of an approval batch, which the retry wrapper fixed.
Spam and moderation drift showed up next. Rate limiting reduced duplicate spam enough for the first version. Upserting user_roles on each access kept usernames in sync after Discord changes. Logging queue failures with a context string made silent failure much easier to spot from the admin view.
This is the general pattern for a tool like this. Solve the next real failure. Keep the code boring.
What I would add next
The next improvements are clear. Push queue updates in real time through server-sent events or a similar lightweight channel. Add a per-user cooldown on top of the IP rate limit. Detect duplicate tracks inside a moving window. Add an optional track length cap. Show upcoming order in the UI. Make the control panel easier to use from a phone or side device. Expand moderation to include soft remove versus hard reject.
Deploy your own
The deployment path is short. Clone the repo. Create a Neon Postgres project. Run the Drizzle migrations. Create a Discord app and set the callback URL. Create a Spotify app and copy the client id and secret. Set the environment variables for Discord, Spotify, and the database. Deploy to Vercel. Log in and approve a test request.
After that, you own the full request path.
Closing
Owning this system lets you choose the friction level. You decide when requests are open, how moderation works, and which failures deserve more guardrails. You are not waiting on a vendor bot to change behavior for your stream.
Source code: https://github.com/oyuh/streamthing