Building a Self-Hosted Spotify Song Request Twitch Panel
/ 9 min read
Overview
You want song requests without chaos. You want control over vibe and spam. Off the shelf bots felt clunky so I built a system I own end to end. Database. Auth. Queue. Moderation. You can replicate it.
You will see each moving part. Auth. Data model. Roles. Queue flow. API layer. Moderation. Token refresh. Limits. Admin panel. Problems. Upgrades. Straight forward. No fluff.
Target outcome. A system you can run locally or deploy. You understand every interaction. You can extend it without waiting on a vendor or reverse engineering opaque behavior.
Why I Built It
I began with a single form. Paste a Spotify link. Submit. First stream test exposed abuse risk fast. Wrong links. Duplicates. Non track URLs. Junk attempts. Needed moderation. Then needed auth. Then rate limiting. Then role control. Then a queue that survived token expiry. Scope growth followed real pain not feature chasing.
Expect the same. Plan for growth pressure. Keep surface small and core solid.
System Overview
Core pieces:
- Discord OAuth based auth and identity cookie
- PostgreSQL with Drizzle for schema and migrations
- Role table to decide mod and ban status
- Raw request intake table for untrusted links
- Approved song table for tracks you will queue
- Spotify token singleton row for access and refresh values
- API routes for user info, requests, moderation, and queue actions
- Admin panel for role toggles and bans
- Rate limiter in memory for spam defense
- Token refresh wrapper with retry
Everything flows through these. Keep them small and predictable.
Authentication Flow Discord OAuth
You log in with Discord. The callback stores a small JSON payload in an http only cookie named discord_user. Contains id and username. That is enough for identity and linking to 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 missing you get blocked. If present we sync or update a record in user_roles so roles stay in the database not only in memory.
Role Sync on Access
On each interaction we upsert the user. That keeps usernames current and ensures a banned flag can take effect fast.
await db.insert(userRoles).values({ id: user.id, username: user.username, isModerator: false, isBanned: false}).onConflictDoUpdate({ target: userRoles.id, set: { username: user.username }})
Enforcement
Moderation only endpoints check role state. Direct simple gate.
if (!role || !role.isModerator) { return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })}
If banned we block submission routes early with the same pattern.
Data Model Structure
You do not need many tables. You need the right ones.
spotify_tokens
Single row. Holds current access and refresh tokens plus timestamp. Lets you refresh without full user login.
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
Raw unapproved links. Status field pending approved rejected. Keeps junk out of approved list until you review.
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
Only approved tracks. Stores normalized metadata so UI stays fast and you are not dependent on repeat Spotify lookups.
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
Decides trust. Moderator flag. Banned flag. Username for display. Insert or update on first hit.
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 index status fields later if volume grows. Early on keep it simple.
Roles and Permissions Logic
Flow when a user acts:
- Read discord_user cookie
- Upsert into user_roles
- If banned block and respond fast
- If endpoint needs moderator check flag
- Proceed
No dynamic rule engine. Flags cover the real cases.
Admin Panel
Path admin users. Requires moderator flag. Server validates on page load. Client still defends but server is the line of truth.
Panel shows id username mod status banned status. Buttons toggle both. Each button sends a PATCH to api users id role with a JSON body containing only changed fields.
Example call
await fetch('/api/users/123456789/role', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isModerator: true })})
Search and filter run in the browser. No extra network or database load.
Request Lifecycle End to End
User submits a Spotify link. We apply a quick parse and validation. If malformed we return an error string. If rate limited we return a wait message. If valid we create a track_requests row with status pending.
Moderator opens review page. Page fetches pending rows. For each approve action we look up track metadata via Spotify API using the current access token. We then insert into song_requests with approved true. Optionally we queue the track to the current playback device.
If Spotify call fails with 401 we refresh tokens and retry once. If still dead we mark an error and skip that approval to avoid silent failure.
Rejected items get status rejected only. We keep them for audit until you prune.
API Layer Summary
Key routes:
- GET api user returns id and username from cookie
- POST api spotify submit link accepts link and requestedBy, applies rate limit, and returns success or error
- GET api spotify requests returns song_requests list for moderators only
- PATCH api spotify requests approves or rejects by id and action
- GET api users returns all user_roles for moderator panel
- GET api users id role returns single role object
- PATCH api users id role updates flags isModerator and/or isBanned
Each route validates early and returns JSON with either data or error field. Consistency matters. Frontend expects shape.
Moderation and Safety Layers
You protect three zones. Input. Rate. Token.
Input Validation
Reject empty link strings. Reject non Spotify domains. Normalize track URLs into URI format once so duplicates compare cleanly.
Rate Limiting
In memory Map keyed by ip. Simple. Five second minimum between submissions. Good enough for a single deployment region.
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}
If you scale to multiple instances move this into Redis with an increment expire pattern.
Ban Enforcement
Check user_roles early. Do not even parse the link if banned.
Error Messaging
Return explicit text like Invalid Spotify track link or Too many requests Please wait. Avoid vague Something failed messaging. The client can show a toast or inline note.
Spotify Token Strategy
Access tokens expire. You cannot trust them. Wrap Spotify calls in a helper that handles refresh once 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 }}
Store new token and timestamp. You can also proactively refresh if age passes threshold. Keep it simple until you see real failures.
Frontend and UX Notes
Next App Router gives server components and route handlers in one structure. Tailwind handles layout and spacing consistency. I kept the UI minimal. Fast forms. Clear colors for approval states. No animation that slows mod clicks. Responsibility of frontend is to call APIs and render factual state not to hold queue logic.
Problems Faced
Issue environment mismatch between local and deployment causing cookie handling differences. Fixed by always setting explicit path slash and using http only cookie APIs.
Issue token expired mid approval batch. Fixed by adding retry wrapper around queue call.
Issue duplicate spam from one viewer. Rate limiting plus future plan to add per user cooldown solved enough.
Issue permission drift when a user changed username. Upsert on every request kept it synced.
Issue risk of queue attempts failing silently. Answer log each failure with context string and surface in admin view. Later you can add persistent log storage.
You will face similar practical problems. Solve the real one in front of you. Keep code boring.
What I Would Add Next
Real time push for queue updates using something lightweight like server sent events. Per user request cooldown distinct from IP rate limit. Automatic duplicate detection within a moving time window. Optional track length cap to block meme noise tracks. UI indicator for upcoming track order. Mobile first control panel for streamer use off main screen. Extending moderation actions to include soft remove vs hard reject.
Deploy Your Own
Steps:
- Clone repo
- Create Neon Postgres project
- Run migrations with Drizzle kit or your chosen script
- Create Discord app; set redirect URL to your deployed domain api discord callback
- Create Spotify app; get client id and secret
- Set environment variables for Discord and Spotify plus database url
- Deploy to Vercel
- Log in approve a song test queue
You now control request flow end to end.
Closing
Owning this system means you can tune friction. You decide when requests open. You decide moderation policy. You are not stuck with a third party bot failure. You can extend it in plain code.
You now have the blueprint. Build it. Strip what you do not need. Add only what earns its keep.
Source code: https://github.com/oyuh/streamthing
Thanks for reading. Ship something fast. Refine it live where real pressure exposes gaps.