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 { // continue } 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}`); });