| import dotenv from "dotenv"; |
| import cors from "cors"; |
| import express from "express"; |
| import crypto from "node:crypto"; |
| import fs from "node:fs"; |
| import fsp from "node:fs/promises"; |
| import os from "node:os"; |
| import path from "node:path"; |
| import { execFile } from "node:child_process"; |
| import { promisify } from "node:util"; |
| import { fileURLToPath } from "node:url"; |
|
|
| dotenv.config({ |
| path: new URL("../.env", import.meta.url) |
| }); |
|
|
| const app = express(); |
| const execFileAsync = promisify(execFile); |
| const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| const clientDistDir = path.join(rootDir, "client", "dist"); |
| const clipCacheDir = path.join(os.tmpdir(), "song-sprint-clips"); |
| const genreLibraryPath = "/data/data/com.termux/files/home/storage/downloads/guessgenres.json"; |
| const port = Number(process.env.PORT || 3001); |
| const clientId = process.env.VITE_CLIENT_ID; |
| const clientSecret = process.env.DISCORD_CLIENT_SECRET; |
| const redirectUri = process.env.DISCORD_REDIRECT_URI || "https://127.0.0.1"; |
| const allowedOrigins = new Set( |
| (process.env.ALLOWED_ORIGINS || "") |
| .split(",") |
| .map((value) => value.trim()) |
| .filter(Boolean) |
| ); |
| const sessions = new Map(); |
| const rooms = new Map(); |
| const clipJobs = new Map(); |
| const REVEAL_SECONDS = 4; |
| let lastAuthDebug = null; |
| let genreLibrary = {}; |
|
|
| app.use(cors({ |
| origin(origin, callback) { |
| if (!origin || allowedOrigins.size === 0 || allowedOrigins.has(origin)) { |
| callback(null, true); |
| return; |
| } |
|
|
| callback(new Error(`Origin not allowed: ${origin}`)); |
| } |
| })); |
| app.use(express.json()); |
|
|
| app.get("/api/config", (_req, res) => { |
| res.json({ |
| clientId: clientId || "", |
| apiBaseUrl: process.env.VITE_API_BASE_URL || "" |
| }); |
| }); |
|
|
| app.get("/api/genres", (_req, res) => { |
| res.json({ |
| genres: listGenres() |
| }); |
| }); |
|
|
| function randomId() { |
| return crypto.randomBytes(16).toString("hex"); |
| } |
|
|
| function slugTitle(value) { |
| return value.toLowerCase().replaceAll(/[^a-z0-9]+/g, " ").trim(); |
| } |
|
|
| function parsePlaylistId(input) { |
| if (!input) { |
| return null; |
| } |
|
|
| const trimmed = input.trim(); |
| const directMatch = trimmed.match(/^[A-Za-z0-9_-]{10,}$/); |
|
|
| if (directMatch) { |
| return directMatch[0]; |
| } |
|
|
| const embeddedMatch = trimmed.match(/[?&]list=([A-Za-z0-9_-]{10,})/i) |
| || trimmed.match(/(?:^|\\s|\\b)list=([A-Za-z0-9_-]{10,})/i) |
| || trimmed.match(/youtube\.com\/playlist\?list=([A-Za-z0-9_-]{10,})/i) |
| || trimmed.match(/music\.youtube\.com\/playlist\?list=([A-Za-z0-9_-]{10,})/i); |
|
|
| if (embeddedMatch) { |
| return embeddedMatch[1]; |
| } |
|
|
| try { |
| const url = new URL(trimmed); |
| return url.searchParams.get("list"); |
| } catch { |
| return null; |
| } |
| } |
|
|
| function parseHumanDuration(value) { |
| if (!value) { |
| return null; |
| } |
|
|
| const parts = value |
| .split(":") |
| .map((part) => Number(part.trim())) |
| .filter((part) => Number.isFinite(part)); |
|
|
| if (!parts.length) { |
| return null; |
| } |
|
|
| let seconds = 0; |
|
|
| for (const part of parts) { |
| seconds = seconds * 60 + part; |
| } |
|
|
| return seconds; |
| } |
|
|
| function shuffle(items) { |
| const clone = [...items]; |
|
|
| for (let i = clone.length - 1; i > 0; i -= 1) { |
| const nextIndex = Math.floor(Math.random() * (i + 1)); |
| [clone[i], clone[nextIndex]] = [clone[nextIndex], clone[i]]; |
| } |
|
|
| return clone; |
| } |
|
|
| function uniqueBy(items, keyFn) { |
| const seen = new Set(); |
|
|
| return items.filter((item) => { |
| const key = keyFn(item); |
|
|
| if (seen.has(key)) { |
| return false; |
| } |
|
|
| seen.add(key); |
| return true; |
| }); |
| } |
|
|
| async function fetchJson(url, init) { |
| const response = await fetch(url, init); |
| const data = await response.json(); |
|
|
| if (!response.ok) { |
| const error = new Error(data.error?.message || data.error || "Request failed"); |
| error.status = response.status; |
| error.payload = data; |
| throw error; |
| } |
|
|
| return data; |
| } |
|
|
| async function fetchText(url, init) { |
| const response = await fetch(url, init); |
| const text = await response.text(); |
|
|
| if (!response.ok) { |
| const error = new Error(`Request failed with ${response.status}.`); |
| error.status = response.status; |
| error.payload = text; |
| throw error; |
| } |
|
|
| return text; |
| } |
|
|
| async function fetchWithRetry(url, init, attempts = 3) { |
| let lastError = null; |
|
|
| for (let attempt = 1; attempt <= attempts; attempt += 1) { |
| try { |
| return await fetch(url, init); |
| } catch (error) { |
| lastError = error; |
|
|
| if (attempt < attempts) { |
| await new Promise((resolve) => setTimeout(resolve, attempt * 500)); |
| } |
| } |
| } |
|
|
| throw lastError; |
| } |
|
|
| function loadGenreLibrary() { |
| try { |
| const raw = fs.readFileSync(genreLibraryPath, "utf8"); |
| const parsed = JSON.parse(raw); |
| genreLibrary = Object.fromEntries( |
| Object.entries(parsed).filter(([, playlists]) => Array.isArray(playlists) && playlists.length > 0) |
| ); |
| } catch { |
| genreLibrary = {}; |
| } |
| } |
|
|
| function listGenres() { |
| return Object.entries(genreLibrary).map(([key, playlists]) => ({ |
| key, |
| label: key.replaceAll(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()), |
| playlistCount: playlists.length |
| })); |
| } |
|
|
| function getPlaylistsForGenre(genreKey) { |
| if (genreKey === "all") { |
| return Object.values(genreLibrary).flat(); |
| } |
|
|
| return genreLibrary[genreKey] || []; |
| } |
|
|
| async function ensureClipCacheDir() { |
| await fsp.mkdir(clipCacheDir, { recursive: true }); |
| } |
|
|
| function getRoundAudioKey(round) { |
| return `${round.videoId}_${round.startSeconds}_${round.clipDurationSeconds}`.replaceAll(/[^A-Za-z0-9._-]+/g, "_"); |
| } |
|
|
| async function createRoundAudioClip(round) { |
| await ensureClipCacheDir(); |
|
|
| const clipKey = getRoundAudioKey(round); |
| const outputPath = path.join(clipCacheDir, `${clipKey}.mp3`); |
|
|
| try { |
| await fsp.access(outputPath); |
| return outputPath; |
| } catch { |
| |
| } |
|
|
| if (clipJobs.has(clipKey)) { |
| return clipJobs.get(clipKey); |
| } |
|
|
| const job = (async () => { |
| const videoUrl = `https://www.youtube.com/watch?v=${round.videoId}`; |
| let stdout; |
|
|
| try { |
| ({ stdout } = await execFileAsync("yt-dlp", [ |
| "-f", "bestaudio", |
| "--no-playlist", |
| "--extractor-args", "youtube:player_client=android,web,tv_embedded", |
| "-g", |
| videoUrl |
| ])); |
| } catch (error) { |
| const stderr = typeof error.stderr === "string" ? error.stderr.trim() : ""; |
| const message = stderr || error.message || "yt-dlp failed to resolve the YouTube audio stream."; |
| throw new Error(message); |
| } |
|
|
| const mediaUrl = stdout.trim().split("\n").find(Boolean); |
|
|
| if (!mediaUrl) { |
| throw new Error("Could not resolve YouTube audio stream."); |
| } |
|
|
| await execFileAsync("ffmpeg", [ |
| "-y", |
| "-ss", String(round.startSeconds), |
| "-t", String(round.clipDurationSeconds), |
| "-i", mediaUrl, |
| "-vn", |
| "-acodec", "libmp3lame", |
| "-ar", "44100", |
| "-ac", "2", |
| outputPath |
| ]); |
|
|
| return outputPath; |
| })(); |
|
|
| clipJobs.set(clipKey, job); |
|
|
| try { |
| return await job; |
| } finally { |
| clipJobs.delete(clipKey); |
| } |
| } |
|
|
| function getTextFromRuns(value) { |
| if (!value) { |
| return null; |
| } |
|
|
| if (typeof value.simpleText === "string") { |
| return value.simpleText; |
| } |
|
|
| if (Array.isArray(value.runs)) { |
| return value.runs.map((entry) => entry.text || "").join("").trim(); |
| } |
|
|
| return null; |
| } |
|
|
| function collectPlaylistVideoRenderers(node, bucket) { |
| if (!node || typeof node !== "object") { |
| return; |
| } |
|
|
| if (Array.isArray(node)) { |
| for (const entry of node) { |
| collectPlaylistVideoRenderers(entry, bucket); |
| } |
| return; |
| } |
|
|
| if (node.playlistVideoRenderer) { |
| bucket.push(node.playlistVideoRenderer); |
| } |
|
|
| for (const value of Object.values(node)) { |
| collectPlaylistVideoRenderers(value, bucket); |
| } |
| } |
|
|
| function extractInitialData(html) { |
| const patterns = [ |
| /var ytInitialData = (\{.*?\});<\/script>/s, |
| /window\["ytInitialData"\] = (\{.*?\});<\/script>/s, |
| /ytInitialData"\] = (\{.*?\});<\/script>/s |
| ]; |
|
|
| for (const pattern of patterns) { |
| const match = html.match(pattern); |
|
|
| if (match) { |
| return JSON.parse(match[1]); |
| } |
| } |
|
|
| throw new Error("Could not extract playlist data from YouTube page."); |
| } |
|
|
| async function fetchPlaylistTracks(playlistId) { |
| const html = await fetchText(`https://www.youtube.com/playlist?list=${encodeURIComponent(playlistId)}`, { |
| headers: { |
| "User-Agent": "Mozilla/5.0" |
| } |
| }); |
| const initialData = extractInitialData(html); |
| const renderers = []; |
|
|
| collectPlaylistVideoRenderers(initialData, renderers); |
|
|
| const entries = renderers.map((renderer) => ({ |
| videoId: renderer.videoId || null, |
| title: getTextFromRuns(renderer.title), |
| thumbnailUrl: renderer.thumbnail?.thumbnails?.at(-1)?.url || null, |
| durationSeconds: Number(renderer.lengthSeconds) || parseHumanDuration(getTextFromRuns(renderer.lengthText)), |
| isPlayable: renderer.isPlayable !== false |
| })); |
|
|
| return uniqueBy( |
| entries.filter((entry) => ( |
| entry.videoId && |
| entry.title && |
| entry.title !== "Deleted video" && |
| entry.title !== "Private video" && |
| entry.isPlayable |
| )), |
| (entry) => entry.videoId |
| ); |
| } |
|
|
| function ensureRoom(roomId) { |
| if (!rooms.has(roomId)) { |
| rooms.set(roomId, { |
| id: roomId, |
| hostUserId: null, |
| settings: { |
| genreKey: "all", |
| roundsCount: 8, |
| clipDurationSeconds: 12 |
| }, |
| playlist: [], |
| playlistSummary: null, |
| players: new Map(), |
| game: { |
| status: "lobby", |
| currentRoundIndex: 0, |
| rounds: [] |
| } |
| }); |
| } |
|
|
| return rooms.get(roomId); |
| } |
|
|
| function requireSession(req) { |
| const token = req.header("X-Session-Token"); |
| const session = token ? sessions.get(token) : null; |
|
|
| if (!session) { |
| const error = new Error("Missing or invalid session."); |
| error.status = 401; |
| throw error; |
| } |
|
|
| return session; |
| } |
|
|
| function getSessionFromRequest(req) { |
| const headerToken = req.header("X-Session-Token"); |
| const queryToken = typeof req.query.sessionToken === "string" ? req.query.sessionToken : null; |
| const token = headerToken || queryToken; |
| const session = token ? sessions.get(token) : null; |
|
|
| if (!session) { |
| const error = new Error("Missing or invalid session."); |
| error.status = 401; |
| throw error; |
| } |
|
|
| return session; |
| } |
|
|
| function ensurePlayer(room, session) { |
| if (!room.players.has(session.user.id)) { |
| room.players.set(session.user.id, { |
| userId: session.user.id, |
| displayName: session.user.global_name || session.user.username, |
| score: 0, |
| lastSeenAt: Date.now() |
| }); |
| } else { |
| const player = room.players.get(session.user.id); |
| player.displayName = session.user.global_name || session.user.username; |
| player.lastSeenAt = Date.now(); |
| } |
|
|
| if (!room.hostUserId) { |
| room.hostUserId = session.user.id; |
| } |
| } |
|
|
| function selectFakeOptions(pool, correctTitle) { |
| const correctSlug = slugTitle(correctTitle); |
| const fakeTitles = uniqueBy( |
| pool.filter((entry) => slugTitle(entry.title) !== correctSlug), |
| (entry) => slugTitle(entry.title) |
| ).map((entry) => entry.title); |
|
|
| return shuffle(fakeTitles).slice(0, 3); |
| } |
|
|
| function pointsForCorrectPosition(position) { |
| if (position === 0) { |
| return 120; |
| } |
|
|
| if (position === 1) { |
| return 85; |
| } |
|
|
| return 60; |
| } |
|
|
| function buildRounds(room) { |
| const { roundsCount, clipDurationSeconds } = room.settings; |
| const eligibleTracks = room.playlist.filter((track) => ( |
| track.durationSeconds === null || |
| track.durationSeconds === undefined || |
| track.durationSeconds >= clipDurationSeconds + 2 |
| )); |
|
|
| if (eligibleTracks.length < roundsCount || room.playlist.length < 4) { |
| const error = new Error("Playlist needs enough playable videos to cover the requested rounds."); |
| error.status = 400; |
| throw error; |
| } |
|
|
| const selectedTracks = shuffle(eligibleTracks).slice(0, roundsCount); |
|
|
| return selectedTracks.map((track, index) => { |
| const fakeTitles = selectFakeOptions(room.playlist, track.title); |
|
|
| if (fakeTitles.length < 3) { |
| const error = new Error("Playlist needs at least four distinct song titles."); |
| error.status = 400; |
| throw error; |
| } |
|
|
| const options = shuffle([ |
| { id: `real:${track.videoId}`, label: track.title }, |
| ...fakeTitles.map((title, fakeIndex) => ({ id: `fake:${index}:${fakeIndex}`, label: title })) |
| ]); |
| const correctOption = options.find((option) => option.id === `real:${track.videoId}`); |
| const knownDuration = Number.isFinite(track.durationSeconds) ? track.durationSeconds : null; |
| const maxStart = knownDuration === null |
| ? Math.max(0, clipDurationSeconds * 2) |
| : Math.max(0, knownDuration - clipDurationSeconds - 1); |
|
|
| return { |
| roundNumber: index + 1, |
| videoId: track.videoId, |
| title: track.title, |
| thumbnailUrl: track.thumbnailUrl, |
| clipDurationSeconds: knownDuration !== null |
| ? Math.min(clipDurationSeconds, Math.max(5, knownDuration)) |
| : clipDurationSeconds, |
| startSeconds: maxStart > 0 ? Math.floor(Math.random() * (maxStart + 1)) : 0, |
| options, |
| correctOptionId: correctOption.id, |
| guesses: [], |
| startedAt: null, |
| revealAt: null, |
| nextRoundAt: null |
| }; |
| }); |
| } |
|
|
| function startRound(room, roundIndex, startedAt) { |
| const round = room.game.rounds[roundIndex]; |
| round.startedAt = startedAt; |
| round.revealAt = startedAt + round.clipDurationSeconds * 1000; |
| round.nextRoundAt = round.revealAt + REVEAL_SECONDS * 1000; |
| } |
|
|
| function syncRoom(room) { |
| if (room.game.status !== "playing") { |
| return; |
| } |
|
|
| const now = Date.now(); |
|
|
| while (room.game.status === "playing") { |
| const round = room.game.rounds[room.game.currentRoundIndex]; |
|
|
| if (!round) { |
| room.game.status = "finished"; |
| break; |
| } |
|
|
| if (round.startedAt === null) { |
| break; |
| } |
|
|
| if (now < round.nextRoundAt) { |
| break; |
| } |
|
|
| room.game.currentRoundIndex += 1; |
|
|
| if (room.game.currentRoundIndex >= room.game.rounds.length) { |
| room.game.status = "finished"; |
| room.game.currentRoundIndex = room.game.rounds.length - 1; |
| break; |
| } |
|
|
| break; |
| } |
| } |
|
|
| function serializeRound(round, userId) { |
| if (!round) { |
| return null; |
| } |
|
|
| const now = Date.now(); |
| const phase = round.startedAt === null |
| ? "pending" |
| : now < round.revealAt |
| ? "guessing" |
| : "reveal"; |
| const myGuess = round.guesses.find((guess) => guess.userId === userId); |
| const correctGuesses = round.guesses.filter((guess) => guess.correct); |
| const fastestCorrect = correctGuesses[0] || null; |
|
|
| return { |
| roundNumber: round.roundNumber, |
| phase, |
| secondsRemaining: phase === "pending" |
| ? round.clipDurationSeconds |
| : Math.max(0, Math.ceil(((phase === "guessing" ? round.revealAt : round.nextRoundAt) - now) / 1000)), |
| clipDurationSeconds: round.clipDurationSeconds, |
| startSeconds: round.startSeconds, |
| videoId: round.videoId, |
| options: round.options, |
| hasGuessed: Boolean(myGuess), |
| myGuessOptionId: myGuess?.optionId || null, |
| correctOptionId: phase === "reveal" ? round.correctOptionId : null, |
| correctTitle: phase === "reveal" ? round.title : null, |
| fastestCorrectPlayerName: fastestCorrect?.displayName || null, |
| fastestCorrectPoints: fastestCorrect?.awardedPoints || 0 |
| }; |
| } |
|
|
| function serializeRoom(room, userId) { |
| syncRoom(room); |
|
|
| const players = [...room.players.values()].sort((left, right) => { |
| if (right.score !== left.score) { |
| return right.score - left.score; |
| } |
|
|
| return left.displayName.localeCompare(right.displayName); |
| }); |
|
|
| return { |
| isHost: room.hostUserId === userId, |
| room: { |
| id: room.id, |
| name: room.id, |
| settings: room.settings, |
| playlistSummary: room.playlistSummary |
| }, |
| players, |
| game: { |
| status: room.game.status, |
| currentRound: serializeRound(room.game.rounds[room.game.currentRoundIndex], userId) |
| } |
| }; |
| } |
|
|
| app.get("/api/health", (_req, res) => { |
| res.json({ ok: true }); |
| }); |
|
|
| app.get("/api/config", (_req, res) => { |
| res.json({ |
| clientId: clientId || null, |
| redirectUri, |
| allowedOrigins: [...allowedOrigins] |
| }); |
| }); |
|
|
| app.get("/api/debug/auth-error", (_req, res) => { |
| res.json(lastAuthDebug || { ok: true }); |
| }); |
|
|
| app.post("/api/token", async (req, res) => { |
| try { |
| lastAuthDebug = { |
| stage: "entered_token_route", |
| timestamp: new Date().toISOString() |
| }; |
|
|
| if (!clientId || !clientSecret) { |
| res.status(500).json({ error: "Missing Discord OAuth environment variables." }); |
| return; |
| } |
|
|
| const { code } = req.body ?? {}; |
|
|
| if (!code) { |
| res.status(400).json({ error: "Missing OAuth authorization code." }); |
| return; |
| } |
|
|
| const body = new URLSearchParams({ |
| client_id: clientId, |
| client_secret: clientSecret, |
| grant_type: "authorization_code", |
| code, |
| redirect_uri: redirectUri |
| }); |
|
|
| const tokenResponse = await fetchWithRetry("https://discord.com/api/v10/oauth2/token", { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/x-www-form-urlencoded" |
| }, |
| body |
| }); |
| const tokenText = await tokenResponse.text(); |
| let tokenData; |
|
|
| try { |
| tokenData = JSON.parse(tokenText); |
| } catch { |
| tokenData = { error: "discord_token_exchange_non_json", raw: tokenText }; |
| } |
|
|
| if (!tokenResponse.ok) { |
| lastAuthDebug = { |
| stage: "token_exchange", |
| status: tokenResponse.status, |
| redirectUri, |
| response: tokenData |
| }; |
| res.status(tokenResponse.status).json(tokenData); |
| return; |
| } |
|
|
| const meResponse = await fetchWithRetry("https://discord.com/api/v10/users/@me", { |
| headers: { |
| Authorization: `Bearer ${tokenData.access_token}` |
| } |
| }); |
| const meText = await meResponse.text(); |
| let meData; |
|
|
| try { |
| meData = JSON.parse(meText); |
| } catch { |
| meData = { error: "discord_me_non_json", raw: meText }; |
| } |
|
|
| if (!meResponse.ok) { |
| lastAuthDebug = { |
| stage: "user_lookup", |
| status: meResponse.status, |
| response: meData |
| }; |
| res.status(meResponse.status).json(meData); |
| return; |
| } |
|
|
| const sessionToken = randomId(); |
| sessions.set(sessionToken, { |
| accessToken: tokenData.access_token, |
| user: meData |
| }); |
| lastAuthDebug = null; |
|
|
| res.json({ |
| access_token: tokenData.access_token, |
| session_token: sessionToken, |
| user: meData |
| }); |
| } catch (error) { |
| lastAuthDebug = { |
| stage: "exception", |
| message: error.message || "Discord token exchange failed.", |
| cause: error.cause?.message || null |
| }; |
| res.status(error.status || 500).json({ |
| error: error.message || "Discord token exchange failed.", |
| cause: error.cause?.message || undefined |
| }); |
| } |
| }); |
|
|
| app.post("/api/room/join", (req, res) => { |
| try { |
| const session = requireSession(req); |
| const roomId = req.body?.roomId; |
|
|
| if (!roomId) { |
| res.status(400).json({ error: "Missing roomId." }); |
| return; |
| } |
|
|
| const room = ensureRoom(roomId); |
| ensurePlayer(room, session); |
| res.json(serializeRoom(room, session.user.id)); |
| } catch (error) { |
| res.status(error.status || 500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.get("/api/room/state", (req, res) => { |
| try { |
| const session = requireSession(req); |
| const room = ensureRoom(req.query.roomId); |
| ensurePlayer(room, session); |
| res.json(serializeRoom(room, session.user.id)); |
| } catch (error) { |
| res.status(error.status || 500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.post("/api/room/settings", async (req, res) => { |
| try { |
| const session = requireSession(req); |
| const room = ensureRoom(req.body?.roomId); |
| ensurePlayer(room, session); |
|
|
| if (room.hostUserId !== session.user.id) { |
| res.status(403).json({ error: "Only the host can update settings." }); |
| return; |
| } |
|
|
| const genreKey = req.body?.genreKey?.trim() || "all"; |
| const roundsCount = Number(req.body?.roundsCount); |
| const clipDurationSeconds = Number(req.body?.clipDurationSeconds); |
|
|
| if (genreKey !== "all" && !genreLibrary[genreKey]) { |
| res.status(400).json({ error: "Select a valid genre." }); |
| return; |
| } |
|
|
| if (!Number.isInteger(roundsCount) || roundsCount < 3 || roundsCount > 20) { |
| res.status(400).json({ error: "Rounds must be between 3 and 20." }); |
| return; |
| } |
|
|
| if (!Number.isInteger(clipDurationSeconds) || clipDurationSeconds < 5 || clipDurationSeconds > 30) { |
| res.status(400).json({ error: "Clip duration must be between 5 and 30 seconds." }); |
| return; |
| } |
|
|
| const playlistUrls = getPlaylistsForGenre(genreKey); |
| const playlistIds = playlistUrls |
| .map((value) => parsePlaylistId(value)) |
| .filter(Boolean); |
|
|
| if (!playlistIds.length) { |
| res.status(400).json({ error: "No playlists configured for that genre." }); |
| return; |
| } |
|
|
| const playlistGroups = await Promise.all(playlistIds.map((playlistId) => fetchPlaylistTracks(playlistId))); |
| const playlist = uniqueBy(playlistGroups.flat(), (entry) => entry.videoId); |
|
|
| room.settings = { |
| genreKey, |
| roundsCount, |
| clipDurationSeconds |
| }; |
| room.playlist = playlist; |
| room.playlistSummary = `${genreKey === "all" ? "All genres" : genreKey} • ${playlist.length} tracks • ${playlistIds.length} playlists`; |
| room.game = { |
| status: "lobby", |
| currentRoundIndex: 0, |
| rounds: [] |
| }; |
|
|
| res.json(serializeRoom(room, session.user.id)); |
| } catch (error) { |
| res.status(error.status || 500).json({ |
| error: error.payload?.error?.message || error.message |
| }); |
| } |
| }); |
|
|
| app.post("/api/room/start", (req, res) => { |
| try { |
| const session = requireSession(req); |
| const room = ensureRoom(req.body?.roomId); |
| ensurePlayer(room, session); |
|
|
| if (room.hostUserId !== session.user.id) { |
| res.status(403).json({ error: "Only the host can start the game." }); |
| return; |
| } |
|
|
| room.game = { |
| status: "playing", |
| currentRoundIndex: 0, |
| rounds: buildRounds(room) |
| }; |
|
|
| for (const player of room.players.values()) { |
| player.score = 0; |
| } |
|
|
| res.json(serializeRoom(room, session.user.id)); |
| } catch (error) { |
| res.status(error.status || 500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.post("/api/room/round-start", (req, res) => { |
| try { |
| const session = requireSession(req); |
| const room = ensureRoom(req.body?.roomId); |
| ensurePlayer(room, session); |
| syncRoom(room); |
|
|
| if (room.hostUserId !== session.user.id) { |
| res.status(403).json({ error: "Only the host can start the round." }); |
| return; |
| } |
|
|
| if (room.game.status !== "playing") { |
| res.status(400).json({ error: "No active game." }); |
| return; |
| } |
|
|
| const round = room.game.rounds[room.game.currentRoundIndex]; |
|
|
| if (!round) { |
| res.status(400).json({ error: "No round available." }); |
| return; |
| } |
|
|
| if (round.startedAt !== null) { |
| res.json(serializeRoom(room, session.user.id)); |
| return; |
| } |
|
|
| startRound(room, room.game.currentRoundIndex, Date.now()); |
| res.json(serializeRoom(room, session.user.id)); |
| } catch (error) { |
| res.status(error.status || 500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.post("/api/room/reset", (req, res) => { |
| try { |
| const session = requireSession(req); |
| const room = ensureRoom(req.body?.roomId); |
| ensurePlayer(room, session); |
|
|
| if (room.hostUserId !== session.user.id) { |
| res.status(403).json({ error: "Only the host can reset the room." }); |
| return; |
| } |
|
|
| room.game = { |
| status: "lobby", |
| currentRoundIndex: 0, |
| rounds: [] |
| }; |
|
|
| for (const player of room.players.values()) { |
| player.score = 0; |
| } |
|
|
| res.json(serializeRoom(room, session.user.id)); |
| } catch (error) { |
| res.status(error.status || 500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.post("/api/room/guess", (req, res) => { |
| try { |
| const session = requireSession(req); |
| const room = ensureRoom(req.body?.roomId); |
| ensurePlayer(room, session); |
| syncRoom(room); |
|
|
| if (room.game.status !== "playing") { |
| res.status(400).json({ error: "No active round." }); |
| return; |
| } |
|
|
| const round = room.game.rounds[room.game.currentRoundIndex]; |
|
|
| if (!round || round.roundNumber !== Number(req.body?.roundNumber)) { |
| res.status(400).json({ error: "Round changed before the guess was received." }); |
| return; |
| } |
|
|
| if (Date.now() >= round.revealAt) { |
| res.status(400).json({ error: "Round is already closed." }); |
| return; |
| } |
|
|
| if (round.guesses.some((guess) => guess.userId === session.user.id)) { |
| res.status(400).json({ error: "You already answered this round." }); |
| return; |
| } |
|
|
| const correct = round.correctOptionId === req.body?.optionId; |
| const player = room.players.get(session.user.id); |
| let awardedPoints = 0; |
|
|
| if (correct) { |
| const position = round.guesses.filter((guess) => guess.correct).length; |
| awardedPoints = pointsForCorrectPosition(position); |
| player.score += awardedPoints; |
| } |
|
|
| round.guesses.push({ |
| userId: session.user.id, |
| displayName: player.displayName, |
| optionId: req.body?.optionId, |
| correct, |
| awardedPoints, |
| guessedAt: Date.now() |
| }); |
|
|
| res.json({ |
| ok: true, |
| correct, |
| awardedPoints |
| }); |
| } catch (error) { |
| res.status(error.status || 500).json({ error: error.message }); |
| } |
| }); |
|
|
| app.get("/api/room/audio", async (req, res) => { |
| try { |
| const session = getSessionFromRequest(req); |
| const room = ensureRoom(req.query.roomId); |
| ensurePlayer(room, session); |
| syncRoom(room); |
|
|
| if (room.game.status !== "playing") { |
| res.status(400).json({ error: "No active game." }); |
| return; |
| } |
|
|
| const roundNumber = Number(req.query.roundNumber); |
| const round = room.game.rounds.find((entry) => entry.roundNumber === roundNumber); |
|
|
| if (!round) { |
| res.status(404).json({ error: "Round not found." }); |
| return; |
| } |
|
|
| const clipPath = await createRoundAudioClip(round); |
| res.setHeader("Content-Type", "audio/mpeg"); |
| res.setHeader("Cache-Control", "public, max-age=86400"); |
| fs.createReadStream(clipPath).pipe(res); |
| } catch (error) { |
| res.status(error.status || 500).json({ error: error.message }); |
| } |
| }); |
|
|
| if (process.env.NODE_ENV === "production") { |
| app.use((req, res, next) => { |
| if (req.path === "/" || req.path.endsWith(".html")) { |
| res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); |
| } else if (req.path.startsWith("/assets/")) { |
| res.setHeader("Cache-Control", "no-store, max-age=0"); |
| } |
|
|
| next(); |
| }); |
|
|
| app.use(express.static(clientDistDir)); |
|
|
| app.get("*", (req, res, next) => { |
| if (req.path.startsWith("/api/")) { |
| next(); |
| return; |
| } |
|
|
| res.sendFile(path.join(clientDistDir, "index.html")); |
| }); |
| } |
|
|
| app.use((error, req, res, _next) => { |
| lastAuthDebug = { |
| stage: "express_error", |
| path: req.path, |
| method: req.method, |
| message: error.message || "Unhandled server error" |
| }; |
|
|
| res.status(error.status || 500).json({ |
| error: error.message || "Unhandled server error" |
| }); |
| }); |
|
|
| loadGenreLibrary(); |
|
|
| app.listen(port, () => { |
| console.log(`Discord Activity server listening on http://127.0.0.1:${port}`); |
| }); |
|
|