skip to content

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:

  1. Discord OAuth based auth and identity cookie
  2. PostgreSQL with Drizzle for schema and migrations
  3. Role table to decide mod and ban status
  4. Raw request intake table for untrusted links
  5. Approved song table for tracks you will queue
  6. Spotify token singleton row for access and refresh values
  7. API routes for user info, requests, moderation, and queue actions
  8. Admin panel for role toggles and bans
  9. Rate limiter in memory for spam defense
  10. 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:

  1. Read discord_user cookie
  2. Upsert into user_roles
  3. If banned block and respond fast
  4. If endpoint needs moderator check flag
  5. 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:

  1. GET api user returns id and username from cookie
  2. POST api spotify submit link accepts link and requestedBy, applies rate limit, and returns success or error
  3. GET api spotify requests returns song_requests list for moderators only
  4. PATCH api spotify requests approves or rejects by id and action
  5. GET api users returns all user_roles for moderator panel
  6. GET api users id role returns single role object
  7. 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:

  1. Clone repo
  2. Create Neon Postgres project
  3. Run migrations with Drizzle kit or your chosen script
  4. Create Discord app; set redirect URL to your deployed domain api discord callback
  5. Create Spotify app; get client id and secret
  6. Set environment variables for Discord and Spotify plus database url
  7. Deploy to Vercel
  8. 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.