import express from 'express'; import { WebSocketServer } from 'ws'; import http from 'http'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; import { v4 as uuidv4 } from 'uuid'; import mime from 'mime'; import { parseFile as mmParseFile } from 'music-metadata'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Config const PORT = process.env.PORT || 5173; const DATA_DIR = path.resolve(__dirname, 'data'); const PUBLIC_DIR = path.resolve(__dirname, 'public'); const YEARS_PATH = path.join(DATA_DIR, 'years.json'); // Ensure data dir exists if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } const app = express(); // Load years.json helper function loadYearsIndex() { try { const raw = fs.readFileSync(YEARS_PATH, 'utf8'); const j = JSON.parse(raw); if (j && j.byFile && typeof j.byFile === 'object') return j.byFile; } catch {} return {}; } let YEARS_INDEX = loadYearsIndex(); app.get('/api/reload-years', (req, res) => { YEARS_INDEX = loadYearsIndex(); res.json({ ok: true, count: Object.keys(YEARS_INDEX).length }); }); // Static files app.use(express.static(PUBLIC_DIR)); // Serve audio files safely from data folder with byte-range support app.head('/audio/:name', (req, res) => { const name = req.params.name; const filePath = path.join(DATA_DIR, name); if (!filePath.startsWith(DATA_DIR)) { return res.status(400).end(); } if (!fs.existsSync(filePath)) { return res.status(404).end(); } const stat = fs.statSync(filePath); const type = mime.getType(filePath) || 'audio/mpeg'; res.setHeader('Accept-Ranges', 'bytes'); res.setHeader('Content-Type', type); res.setHeader('Content-Length', stat.size); res.setHeader('Cache-Control', 'no-store'); return res.status(200).end(); }); app.get('/audio/:name', (req, res) => { const name = req.params.name; const filePath = path.join(DATA_DIR, name); if (!filePath.startsWith(DATA_DIR)) { return res.status(400).send('Invalid path'); } if (!fs.existsSync(filePath)) { return res.status(404).send('Not found'); } const stat = fs.statSync(filePath); const range = req.headers.range; const type = mime.getType(filePath) || 'audio/mpeg'; res.setHeader('Accept-Ranges', 'bytes'); res.setHeader('Cache-Control', 'no-store'); if (range) { const match = /bytes=(\d+)-(\d+)?/.exec(range); let start = match && match[1] ? parseInt(match[1], 10) : 0; let end = match && match[2] ? parseInt(match[2], 10) : stat.size - 1; if (Number.isNaN(start)) start = 0; if (Number.isNaN(end)) end = stat.size - 1; // Clamp and validate start = Math.min(Math.max(0, start), Math.max(0, stat.size - 1)); end = Math.min(Math.max(start, end), Math.max(0, stat.size - 1)); if (start > end || start >= stat.size) { res.setHeader('Content-Range', `bytes */${stat.size}`); return res.status(416).end(); } const chunkSize = end - start + 1; res.writeHead(206, { 'Content-Range': `bytes ${start}-${end}/${stat.size}`, 'Content-Length': chunkSize, 'Content-Type': type, }); fs.createReadStream(filePath, { start, end }).pipe(res); } else { res.writeHead(200, { 'Content-Length': stat.size, 'Content-Type': type, }); fs.createReadStream(filePath).pipe(res); } }); // List tracks with minimal metadata app.get('/api/tracks', async (req, res) => { try { const files = fs .readdirSync(DATA_DIR) .filter((f) => /\.(mp3|wav|m4a|ogg)$/i.test(f)); const tracks = await Promise.all( files.map(async (f) => { const fp = path.join(DATA_DIR, f); let year = null; let title = path.parse(f).name; let artist = ''; try { const meta = await mmParseFile(fp, { duration: false }); title = meta.common.title || title; artist = meta.common.artist || artist; year = meta.common.year || null; } catch {} const y = YEARS_INDEX[f]?.year ?? year; return { id: f, file: f, title, artist, year: y }; }) ); res.json({ tracks }); } catch (e) { res.status(500).json({ error: e.message }); } }); const server = http.createServer(app); // --- Game State --- const rooms = new Map(); // roomId -> { id, name, hostId, players: Map, state } function createRoom(name, host) { const id = (Math.random().toString(36).slice(2, 8)).toUpperCase(); const room = { id, name: name || `Room ${id}`, hostId: host.id, players: new Map([[host.id, host]]), deck: [], discard: [], revealTimer: null, syncTimer: null, state: { status: 'lobby', // lobby | playing | ended turnOrder: [], currentGuesser: null, currentTrack: null, timeline: {}, // playerId -> [{trackId, year, title, artist}] tokens: {}, // playerId -> number ready: { [host.id]: false }, // playerId -> boolean spectators: {}, // playerId -> boolean (joined after game start) phase: 'guess', // 'guess' | 'reveal' lastResult: null, // { playerId, correct } trackStartAt: null, // ms epoch for synced start time paused: false, pausedPosSec: 0, goal: 10, }, }; rooms.set(id, room); return room; } function broadcast(room, type, payload) { for (const p of room.players.values()) { try { p.ws.send(JSON.stringify({ type, ...payload })); } catch {} } } function roomSummary(room) { return { id: room.id, name: room.name, hostId: room.hostId, players: [...room.players.values()].map((p) => ({ id: p.id, name: p.name, connected: p.connected, ready: !!room.state.ready?.[p.id], spectator: !!room.state.spectators?.[p.id] || !!p.spectator, })), state: room.state, }; } async function loadDeck() { // Load directly from DATA_DIR to avoid HTTP dependency const files = fs .readdirSync(DATA_DIR) .filter((f) => /\.(mp3|wav|m4a|ogg)$/i.test(f)); const tracks = await Promise.all( files.map(async (f) => { const fp = path.join(DATA_DIR, f); let year = null; let title = path.parse(f).name; let artist = ''; try { const meta = await mmParseFile(fp, { duration: false }); title = meta.common.title || title; artist = meta.common.artist || artist; year = meta.common.year || null; } catch {} const y = YEARS_INDEX[f]?.year ?? year; return { id: f, file: f, title, artist, year: y }; }) ); return tracks; } function nextPlayer(turnOrder, currentId) { if (!turnOrder.length) return null; if (!currentId) return turnOrder[0]; const idx = turnOrder.indexOf(currentId); return turnOrder[(idx + 1) % turnOrder.length]; } function shuffle(arr) { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } function startSyncTimer(room) { if (room.syncTimer) clearInterval(room.syncTimer); room.syncTimer = setInterval(() => { if (room.state.status !== 'playing' || !room.state.currentTrack || !room.state.trackStartAt || room.state.paused) return; broadcast(room, 'sync', { startAt: room.state.trackStartAt, serverNow: Date.now() }); }, 1000); } function stopSyncTimer(room) { if (room.syncTimer) { clearInterval(room.syncTimer); room.syncTimer = null; } } function drawNextTrack(room) { const track = room.deck.shift(); if (!track) { room.state.status = 'ended'; room.state.winner = null; // deck exhausted broadcast(room, 'game_ended', { winner: null }); return; } room.state.currentTrack = { ...track, url: `/audio/${encodeURIComponent(track.file)}` }; room.state.phase = 'guess'; room.state.lastResult = null; room.state.paused = false; room.state.pausedPosSec = 0; room.state.trackStartAt = Date.now() + 800; // synchronized start in ~0.8s broadcast(room, 'play_track', { track: room.state.currentTrack, startAt: room.state.trackStartAt, serverNow: Date.now() }); broadcast(room, 'room_update', { room: roomSummary(room) }); startSyncTimer(room); } // WebSocket handling const wss = new WebSocketServer({ server }); wss.on('connection', (ws) => { const id = uuidv4(); const player = { id, name: `Player-${id.slice(0, 4)}`, ws, connected: true, roomId: null }; function send(type, payload) { try { ws.send(JSON.stringify({ type, ...payload })); } catch {} } send('connected', { playerId: id }); ws.on('message', async (raw) => { let msg; try { msg = JSON.parse(raw.toString()); } catch { return; } if (msg.type === 'player_control') { const room = rooms.get(player.roomId); if (!room) return; const { action } = msg; // 'play' | 'pause' if (room.state.status !== 'playing') return; if (room.state.phase !== 'guess') return; // control only while guessing if (room.state.currentGuesser !== player.id) return; // only current guesser controls if (!room.state.currentTrack) return; if (action !== 'play' && action !== 'pause') return; if (action === 'pause') { if (!room.state.paused) { const now = Date.now(); if (room.state.trackStartAt) { room.state.pausedPosSec = Math.max(0, (now - room.state.trackStartAt) / 1000); } room.state.paused = true; stopSyncTimer(room); } broadcast(room, 'control', { action: 'pause' }); } else if (action === 'play') { const now = Date.now(); const posSec = room.state.paused ? room.state.pausedPosSec : Math.max(0, (now - (room.state.trackStartAt || now)) / 1000); room.state.trackStartAt = now - Math.floor(posSec * 1000); room.state.paused = false; startSyncTimer(room); broadcast(room, 'control', { action: 'play', startAt: room.state.trackStartAt, serverNow: now }); } return; } if (msg.type === 'set_name') { player.name = String(msg.name || '').slice(0, 30) || player.name; if (player.roomId && rooms.has(player.roomId)) { broadcast(rooms.get(player.roomId), 'room_update', { room: roomSummary(rooms.get(player.roomId)) }); } return; } if (msg.type === 'create_room') { const room = createRoom(msg.name, player); player.roomId = room.id; broadcast(room, 'room_update', { room: roomSummary(room) }); return; } if (msg.type === 'join_room') { const code = String(msg.code || '').toUpperCase(); const room = rooms.get(code); if (!room) return send('error', { message: 'Room not found' }); room.players.set(player.id, player); player.roomId = room.id; room.state.ready[player.id] = false; // If the game already started, mark as spectator if (room.state.status === 'playing' || room.state.status === 'ended') { room.state.spectators[player.id] = true; player.spectator = true; } else { delete room.state.spectators[player.id]; player.spectator = false; } broadcast(room, 'room_update', { room: roomSummary(room) }); return; } if (msg.type === 'leave_room') { if (!player.roomId) return; const room = rooms.get(player.roomId); if (!room) return; room.players.delete(player.id); player.roomId = null; if (room.state.ready) delete room.state.ready[player.id]; if (room.state.spectators) delete room.state.spectators[player.id]; if (room.players.size === 0) rooms.delete(room.id); else broadcast(room, 'room_update', { room: roomSummary(room) }); return; } if (msg.type === 'set_ready') { const room = rooms.get(player.roomId); if (!room) return; const value = !!msg.ready; room.state.ready[player.id] = value; broadcast(room, 'room_update', { room: roomSummary(room) }); return; } if (msg.type === 'start_game') { const room = rooms.get(player.roomId); if (!room) return; if (room.hostId !== player.id) return send('error', { message: 'Only host can start' }); // All players must be ready const pids = [...room.players.values()] .filter(p => !room.state.spectators?.[p.id]) .map(p => p.id); const allReady = pids.every((pid) => !!room.state.ready?.[pid]); if (!allReady) return send('error', { message: 'All players must be ready' }); room.state.status = 'playing'; room.state.turnOrder = shuffle(pids); room.state.currentGuesser = room.state.turnOrder[0]; room.state.timeline = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, []])); room.state.tokens = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, 2])); room.deck = shuffle(await loadDeck()); room.discard = []; room.state.phase = 'guess'; room.state.lastResult = null; // draw first track automatically drawNextTrack(room); return; } // 'scan_track' is no longer used; server auto draws. if (msg.type === 'place_guess') { const room = rooms.get(player.roomId); if (!room) return; const { position, slot: rawSlot } = msg; // 'before' | 'after' | slot index if (room.state.status !== 'playing') return send('error', { message: 'Game not playing' }); if (room.state.phase !== 'guess') return send('error', { message: 'Not accepting guesses now' }); if (room.state.currentGuesser !== player.id) return send('error', { message: 'Not your turn' }); // Simplified: if year known, check relative placement against player's timeline const current = room.state.currentTrack; if (!current) return send('error', { message: 'No current track' }); const tl = room.state.timeline[player.id] || []; const n = tl.length; let slot = Number.isInteger(rawSlot) ? rawSlot : null; if (slot == null) { if (position === 'before') slot = 0; else if (position === 'after') slot = n; } if (typeof slot !== 'number' || slot < 0 || slot > n) slot = n; // default to after let correct = false; if (current.year != null) { if (n === 0) { correct = slot === 0; // only one slot } else { const left = slot > 0 ? tl[slot - 1]?.year : null; const right = slot < n ? tl[slot]?.year : null; const leftOk = (left == null) || (current.year >= left); const rightOk = (right == null) || (current.year <= right); correct = leftOk && rightOk; } } if (correct) { // Insert at chosen slot; equal years allowed anywhere const newTl = tl.slice(); newTl.splice(slot, 0, { trackId: current.id, year: current.year, title: current.title, artist: current.artist }); room.state.timeline[player.id] = newTl; } else { room.discard.push(current); } // Enter reveal phase (song keeps playing), announce result room.state.phase = 'reveal'; room.state.lastResult = { playerId: player.id, correct }; broadcast(room, 'reveal', { result: room.state.lastResult, track: room.state.currentTrack }); // Also push a room update so clients know we're in 'reveal' (for Next button visibility) broadcast(room, 'room_update', { room: roomSummary(room) }); // Win check after revealing const tlNow = room.state.timeline[player.id] || []; if (correct && tlNow.length >= room.state.goal) { room.state.status = 'ended'; room.state.winner = player.id; // Inform game ended immediately broadcast(room, 'game_ended', { winner: player.id }); return; } // Manual advance: wait for 'next_track' return; } if (msg.type === 'earn_token') { const room = rooms.get(player.roomId); if (!room) return; const tokens = room.state.tokens[player.id] ?? 0; room.state.tokens[player.id] = Math.min(5, tokens + 1); broadcast(room, 'room_update', { room: roomSummary(room) }); return; } if (msg.type === 'next_track') { const room = rooms.get(player.roomId); if (!room) return; if (room.state.status !== 'playing') return; if (room.state.phase !== 'reveal') return; // can only advance during reveal const isAuthorized = player.id === room.hostId || player.id === room.state.currentGuesser; if (!isAuthorized) return; // Advance to next round room.state.currentTrack = null; room.state.trackStartAt = null; room.state.paused = false; room.state.pausedPosSec = 0; stopSyncTimer(room); room.state.currentGuesser = nextPlayer(room.state.turnOrder, room.state.currentGuesser); room.state.phase = 'guess'; broadcast(room, 'room_update', { room: roomSummary(room) }); drawNextTrack(room); return; } }); ws.on('close', () => { player.connected = false; if (player.roomId && rooms.has(player.roomId)) { const room = rooms.get(player.roomId); // Keep player in room but mark disconnected broadcast(room, 'room_update', { room: roomSummary(room) }); } }); }); server.listen(PORT, () => { console.log(`Hitstar server running on http://localhost:${PORT}`); });