Compare commits

..

12 Commits

Author SHA1 Message Date
9373726347 feat: Implement initial client-side UI rendering for game rooms, player dashboards, and track timelines, alongside core WebSocket server functionality.
All checks were successful
Build and Push Docker Image / docker (push) Successful in 13s
2026-01-04 18:24:06 +01:00
a1f1b41987 feat: Implement initial real-time multiplayer game server and client-side UI for core game functionality.
All checks were successful
Build and Push Docker Image / docker (push) Successful in 9s
2026-01-04 15:10:34 +01:00
8ca744cd5b feat: Implement client-side game logic and UI with WebSocket message handlers, including a winner popup and confetti animation.
All checks were successful
Build and Push Docker Image / docker (push) Successful in 9s
2026-01-04 12:14:53 +01:00
c9be49d988 feat: implement initial Deno server with WebSocket API, static file serving, and development Docker Compose.
All checks were successful
Build and Push Docker Image / docker (push) Successful in 21s
2026-01-03 22:07:34 +01:00
70be1e7e39 feat: Enhance iOS Safari audio streaming support with optimizations and specific headers
All checks were successful
Build and Push Docker Image / docker (push) Successful in 9s
2025-10-19 23:18:25 +02:00
18d14b097d feat: Integrate Howler.js for audio playback and remove native audio elements
All checks were successful
Build and Push Docker Image / docker (push) Successful in 9s
2025-10-19 22:55:49 +02:00
1dbae8b62b fix: remove unused volume mapping for public directory in docker-compose
All checks were successful
Build and Push Docker Image / docker (push) Successful in 6s
2025-10-19 22:24:13 +02:00
47b0caa52b Merge pull request 'deno-rewrite' (#3) from deno-rewrite into main
All checks were successful
Build and Push Docker Image / docker (push) Successful in 18s
Reviewed-on: #3
2025-10-19 22:09:40 +02:00
445f522fa8 Refactor code structure for improved readability and maintainability 2025-10-19 22:08:17 +02:00
bec0a3b72f Merge remote-tracking branch 'origin/main' into deno-rewrite 2025-10-19 22:00:50 +02:00
ab87e65e18 fix: clarify scoring system in track placement and guessing logic 2025-10-15 23:42:50 +02:00
58c668de63 Refactor: Remove audio processing and game state management modules 2025-10-15 23:33:40 +02:00
82 changed files with 10914 additions and 6527 deletions

View File

@@ -11,4 +11,11 @@ data/*
*.wav
*.m4a
*.ogg
*.flac
*.flac
**/data/*.mp3
**/data/*.wav
**/data/*.m4a
**/data/*.opus
**/data/*.ogg
**/data/*.flac

View File

@@ -1,27 +0,0 @@
module.exports = {
root: true,
env: {
node: true,
es2023: true,
browser: true,
},
parserOptions: {
ecmaVersion: 2023,
sourceType: 'module',
},
ignores: ['data/**', 'public/**/vendor/**', 'scripts/**/tmp/**', 'tmp/**'],
plugins: ['import'],
extends: [
'eslint:recommended',
'plugin:import/recommended',
'plugin:import/errors',
'plugin:import/warnings',
'plugin:import/typescript',
'prettier',
],
rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'no-console': 'off',
'import/no-unresolved': 'off',
},
};

View File

@@ -165,6 +165,7 @@ jobs:
echo "Building $IMAGE_FULL with tags: $TAG_ARGS"
docker buildx build \
--platform linux/amd64 \
--target production \
-f "$DOCKERFILE" \
$TAG_ARGS \
--cache-from type=registry,ref="$IMAGE_FULL:buildcache" \

View File

@@ -1,8 +0,0 @@
data/
public/audio/
public/cover/
**/*.mp3
node_modules/
.tmp/
dist/
coverage/

View File

@@ -1,7 +0,0 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2
}

View File

@@ -1,23 +1,52 @@
# Lightweight production image for the Hitstar Node app
FROM node:22-alpine
# Multi-stage Dockerfile for Hitstar Deno Server
# Supports both development and production environments
# Base stage with common dependencies
FROM denoland/deno:latest AS base
WORKDIR /app
# Install dependencies
COPY package*.json ./
# Use npm ci when a lockfile is present, otherwise fallback to npm install without throwing an error
RUN if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then \
npm ci --omit=dev; \
else \
npm install --omit=dev; \
fi
# Copy all source files first for dependency resolution
COPY src/server-deno/ .
# Copy app source (media lives outside via volume)
COPY . .
# Cache all dependencies based on deno.json imports
RUN deno cache main.ts
ENV NODE_ENV=production \
# Development stage
FROM base AS development
ENV DENO_ENV=development \
PORT=5173
EXPOSE 5173
CMD ["node", "src/server/index.js", "--host", "0.0.0.0"]
# Copy all source files
COPY src/server-deno/ .
# Run with watch mode and all necessary permissions
CMD ["deno", "run", \
"--allow-net", \
"--allow-read", \
"--allow-env", \
"--allow-write", \
"--watch", \
"main.ts"]
# Production stage
FROM base AS production
ENV DENO_ENV=production \
PORT=5173
EXPOSE 5173
# Copy only necessary source files for production
COPY src/server-deno/ .
# Run optimized production server
CMD ["deno", "run", \
"--allow-net", \
"--allow-read=/app/data,/app/public", \
"--allow-env", \
"--allow-write=/app/data", \
"main.ts"]

160
README.md
View File

@@ -1,115 +1,75 @@
# Hitstar lokale Web-App (Prototyp)
# Hitstar Backend - Deno 2 + TypeScript
## Docker
Modern backend rewrite using Deno 2 and TypeScript with clean architecture principles.
Run the app in a container while using your local `data/` music folder:
## Architecture
1. Build the image
```powershell
docker compose build
```
2. Start the service
```powershell
docker compose up -d
```
3. Open http://localhost:5173
Notes:
- Your local `data/` is mounted read/write at `/app/data` inside the container, so you can manage tracks on the host.
- To rebuild after changes: `docker compose build --no-cache && docker compose up -d`.
Lokales Multiplayer-Webspiel inspiriert von HITSTER. Nutzt eure MP3-Dateien im Ordner `data/`, eine Lobby mit Raum-Code sowie WebSockets für den Mehrspieler-Modus.
## Features
- Lobby mit Raum-Erstellung und -Beitritt (Code)
- Mehrere Spieler pro Raum, Host startet das Spiel
- Lokale MP3-Wiedergabe via Browser-Audio (`/audio/<dateiname>`) keine externen Dienste
- Einfache Rundenlogik: DJ scannt Lied, Spieler raten vor/nach (vereinfachte Chronologie)
- Token-Zähler (Basis); Gewinnbedingung: 10 korrekt platzierte Karten
Hinweis: Regeln sind vereinfacht; „HITSTER!“-Challenges und exakter Zwischenplatzierungsmodus sind als Ausbaustufe geplant.
## Setup
1. MP3-Dateien in `data/` legen (Dateiname wird als Fallback-Titel genutzt; falls Tags vorhanden, werden Titel/Künstler/Jahr ausgelesen).
2. Abhängigkeiten installieren und Server starten.
### PowerShell-Befehle
```powershell
# In den Projektordner wechseln
Set-Location e:\git\hitstar
# Abhängigkeiten installieren
npm install
# Server starten
npm start
```
Dann im Browser öffnen: http://localhost:5173
## Nutzung
- Namen eingeben (speichert automatisch), Raum erstellen oder mit Code beitreten (Code wird angezeigt).
- Host klickt „Spiel starten“.
- DJ klickt „Lied scannen“; der Track spielt bei allen.
- Aktiver Spieler wählt „Vor“ oder „Nach“. Bei Erfolg wandert das Lied in seine Zeitleiste.
## Ordnerstruktur
- `public/` Client (HTML/CSS/JS)
- `src/server/` Express + WebSocket Server, Game-State
- `data/` eure Audio-Dateien und Playlists
### Playlist-Unterstützung
Die App unterstützt jetzt mehrere Playlists! Du kannst verschiedene Playlists für verschiedene Spielsessions erstellen:
**Ordnerstruktur für Playlists:**
This backend follows **Clean Architecture** principles with clear separation of concerns:
```
data/
├── (Audio-Dateien hier = "Default" Playlist)
├── 80s-Hits/
│ ├── Song1.opus
├── Song2.opus
│ └── ...
├── Rock-Classics/
│ ├── Song1.opus
│ └── ...
└── Party-Mix/
├── Song1.opus
└── ...
src/server-deno/
├── domain/ # Domain models, types, and business rules (no dependencies)
├── application/ # Use cases and application services
├── infrastructure/ # External concerns (file system, networking, etc.)
├── presentation/ # HTTP routes, WebSocket handlers
├── shared/ # Shared utilities, constants, types
└── main.ts # Application entry point and DI setup
```
**So funktioniert's:**
### Layers
1. **Standard-Playlist**: Audio-Dateien direkt im `data/`-Ordner werden als "Default"-Playlist erkannt
2. **Eigene Playlists**: Erstelle Unterordner im `data/`-Verzeichnis, z.B. `data/80s-Hits/`
3. **Playlist-Auswahl**: Als Raum-Host kannst du in der Lobby die gewünschte Playlist auswählen, bevor das Spiel startet
4. **Unterstützte Formate**: .mp3, .wav, .m4a, .ogg, .opus
1. **Domain Layer**: Pure business logic and types
- No external dependencies
- Models: Player, Room, Track, GameState
- Domain services for core game logic
**Empfehlung**: Nutze das `.opus`-Format für optimale Streaming-Performance und geringeren Speicherverbrauch. Das Konvertierungsskript `npm run audio:convert` wandelt automatisch alle Audio-Dateien in Opus um.
2. **Application Layer**: Use cases and orchestration
- Game service (start game, process guesses, etc.)
- Track service (load tracks, manage playlists)
- Room service (create/join rooms, manage players)
## Git & Audio-Dateien
3. **Infrastructure Layer**: External concerns
- File system operations
- Audio streaming
- Token management
- Metadata parsing
- In `.gitignore` sind alle gängigen Audio-Dateitypen ausgeschlossen (z. B. .mp3, .wav, .flac, .m4a, .ogg, …).
- Legt eure Musik lokal in `data/`. Diese Dateien werden nicht ins Git-Repo eingecheckt und bleiben nur auf eurem Rechner.
4. **Presentation Layer**: API and WebSocket
- REST routes for HTTP endpoints
- Socket.IO handlers for real-time game
## Nächste Schritte (optional)
## Running
- „HITSTER!“-Challenges per Token mit Positionsauswahl (zwischen zwei Karten)
- Team-Modus, Pro-/Expert-Regeln, exaktes Jahr
- Persistenz (Räume/Spielstände), Reconnect
- Drag & Drop-Zeitleiste, visuelle Platzierung
### Development
```bash
deno task dev
```
## Hinweis
### Production
```bash
deno task start
```
Nur für privaten Gebrauch. Musikdateien bleiben lokal bei euch.
### Testing
```bash
deno task test
deno task test:watch
```
### Code Quality
```bash
deno task lint
deno task fmt
deno task check
```
## Dependencies
- **@oak/oak**: Modern HTTP framework for Deno
- **socket.io**: Real-time bidirectional event-based communication
- **music-metadata**: Audio file metadata parsing
- **lru-cache**: Token and cover art caching
## Environment Variables
See `.env.example` for all available configuration options.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

File diff suppressed because it is too large Load Diff

44
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,44 @@
# Development Docker Compose for Hitstar
# Enables hot reload and debugging for local development
#
# Usage:
# docker compose -f docker-compose.dev.yml up --build
#
# Debugging:
# Connect to chrome://inspect or VS Code debugger at localhost:9229
services:
hitstar-dev:
build:
context: .
dockerfile: Dockerfile
target: development
image: hitstar-deno:dev
container_name: hitstar-dev
environment:
- DENO_ENV=development
- PORT=5173
ports:
# Application port
- "5173:5173"
# Deno inspector/debugger port
- "9229:9229"
volumes:
# Mount source code for hot reload
- ./src/server-deno:/app:cached
# Mount data directory
- ./data:/app/data
# Override CMD to enable debugging with inspector
command: >
deno run --allow-net --allow-read --allow-env --allow-write --watch --inspect=0.0.0.0:9229 main.ts
networks:
- hitstar-dev-network
# Restart on crash during development
restart: unless-stopped
# Enable stdin for interactive debugging
stdin_open: true
tty: true
networks:
hitstar-dev-network:
driver: bridge

View File

@@ -1,13 +1,23 @@
services:
# Production service
hitstar:
build: .
image: hitstar-webapp:latest
build:
context: .
dockerfile: Dockerfile
target: production
image: hitstar-deno:prod
container_name: hitstar
environment:
- NODE_ENV=production
- DENO_ENV=production
- PORT=5173
ports:
- "5173:5173"
- "0.0.0.0:5173:5173"
volumes:
- ./data:/app/data:rw
restart: unless-stopped
- ./data:/app/data:ro
restart: unless-stopped
networks:
- hitstar-network
networks:
hitstar-network:
driver: bridge

View File

@@ -1,32 +0,0 @@
import js from '@eslint/js';
import globals from 'globals';
export default [
{
files: ['**/*.js'],
ignores: [
'node_modules/**',
'data/**',
'public/audio/**',
'public/cover/**',
'**/*.mp3',
'.tmp/**',
'dist/**',
'coverage/**',
],
languageOptions: {
ecmaVersion: 2023,
sourceType: 'module',
globals: {
...globals.node,
...globals.browser,
},
},
rules: {
...js.configs.recommended.rules,
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'no-console': 'off',
'no-empty': ['warn', { allowEmptyCatch: true }],
},
},
];

View File

@@ -1,40 +0,0 @@
{
"name": "hitstar-webapp",
"version": "0.1.0",
"private": true,
"description": "Local Hitster-like multiplayer web app using WebSockets and local MP3s",
"main": "src/server/index.js",
"type": "module",
"scripts": {
"start": "node src/server/index.js",
"dev": "nodemon src/server/index.js",
"audio:convert": "node scripts/convert-to-opus.js",
"audio:convert:dry": "node scripts/convert-to-opus.js --dry-run",
"years:resolve": "node scripts/resolve-years.js",
"years:resolve:10": "node scripts/resolve-years.js --max 10",
"years:force": "node scripts/resolve-years.js --force",
"lint": "eslint . --ext .js",
"lint:fix": "eslint . --ext .js --fix",
"format": "prettier --write \"**/*.{js,json,md,css,html}\"",
"format:check": "prettier --check \"**/*.{js,json,md,css,html}\""
},
"dependencies": {
"express": "^4.19.2",
"lru-cache": "^11.0.0",
"mime": "^3.0.0",
"music-metadata": "^7.14.0",
"socket.io": "^4.7.5",
"undici": "^6.19.8",
"uuid": "^9.0.1"
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"ffmpeg-static": "^5.2.0",
"eslint": "^9.11.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"globals": "^13.24.0",
"nodemon": "^3.1.0",
"prettier": "^3.3.3"
}
}

View File

@@ -1,77 +0,0 @@
import { $audio, $bufferBadge, $progressFill, $recordDisc } from './dom.js';
import { state } from './state.js';
export function initAudioUI() {
try {
if ('preservesPitch' in $audio) $audio.preservesPitch = true;
if ('mozPreservesPitch' in $audio) $audio.mozPreservesPitch = true;
if ('webkitPreservesPitch' in $audio) $audio.webkitPreservesPitch = true;
} catch {}
$audio.addEventListener('timeupdate', () => {
const dur = $audio.duration || 0;
if (!dur || !$progressFill) return;
const pct = Math.min(100, Math.max(0, ($audio.currentTime / dur) * 100));
$progressFill.style.width = pct + '%';
});
const showBuffer = (v) => {
state.isBuffering = v;
if ($bufferBadge) $bufferBadge.classList.toggle('hidden', !v);
if ($recordDisc) $recordDisc.classList.toggle('spin-record', !v && !$audio.paused);
};
$audio.addEventListener('waiting', () => showBuffer(true));
$audio.addEventListener('stalled', () => showBuffer(true));
$audio.addEventListener('canplay', () => showBuffer(false));
$audio.addEventListener('playing', () => showBuffer(false));
$audio.addEventListener('ended', () => {
if ($recordDisc) $recordDisc.classList.remove('spin-record');
$audio.playbackRate = 1.0;
});
}
export function applySync(startAt, serverNow) {
if (!startAt || !serverNow) return;
if (state.room?.state?.paused) return;
if (state.isBuffering) return;
const now = Date.now();
const elapsed = (now - startAt) / 1000;
const drift = ($audio.currentTime || 0) - elapsed;
const abs = Math.abs(drift);
if (abs > 1.0) {
$audio.currentTime = Math.max(0, elapsed);
if ($audio.paused) $audio.play().catch(() => {});
$audio.playbackRate = 1.0;
} else if (abs > 0.12) {
const maxNudge = 0.03;
const sign = drift > 0 ? -1 : 1;
const rate = 1 + sign * Math.min(maxNudge, abs * 0.5);
$audio.playbackRate = Math.max(0.8, Math.min(1.2, rate));
} else {
if (Math.abs($audio.playbackRate - 1) > 0.001) {
$audio.playbackRate = 1.0;
}
}
}
export function stopAudioPlayback() {
try {
$audio.pause();
} catch {}
try {
$audio.currentTime = 0;
} catch {}
try {
$audio.src = '';
} catch {}
try {
$audio.playbackRate = 1.0;
} catch {}
try {
if ($recordDisc) $recordDisc.classList.remove('spin-record');
} catch {}
try {
if ($progressFill) $progressFill.style.width = '0%';
} catch {}
try {
if ($bufferBadge) $bufferBadge.classList.add('hidden');
} catch {}
}

View File

@@ -1,58 +0,0 @@
const el = (id) => document.getElementById(id);
export const $lobby = el('lobby');
export const $room = el('room');
export const $roomId = el('roomId');
export const $nameDisplay = el('nameDisplay');
export const $status = el('status');
export const $guesser = el('guesser');
export const $timeline = el('timeline');
export const $audio = el('audio');
export const $np = el('nowPlaying');
export const $npTitle = el('npTitle');
export const $npArtist = el('npArtist');
export const $npYear = el('npYear');
export const $readyChk = el('readyChk');
export const $startGame = el('startGame');
export const $revealBanner = el('revealBanner');
export const $placeArea = el('placeArea');
export const $slotSelect = el('slotSelect');
export const $placeBtn = el('placeBtn');
export const $mediaControls = el('mediaControls');
export const $playBtn = el('playBtn');
export const $pauseBtn = el('pauseBtn');
export const $nextArea = el('nextArea');
export const $nextBtn = el('nextBtn');
export const $recordDisc = el('recordDisc');
export const $progressFill = el('progressFill');
export const $volumeSlider = el('volumeSlider');
export const $bufferBadge = el('bufferBadge');
export const $copyRoomCode = el('copyRoomCode');
export const $nameLobby = el('name');
export const $saveName = el('saveName');
export const $createRoom = el('createRoom');
export const $joinRoom = el('joinRoom');
export const $roomCode = el('roomCode');
export const $leaveRoom = el('leaveRoom');
export const $earnToken = el('earnToken');
export const $dashboardList = el('dashboardList');
export const $toast = el('toast');
// Answer form elements
export const $answerForm = el('answerForm');
export const $guessTitle = el('guessTitle');
export const $guessArtist = el('guessArtist');
export const $answerResult = el('answerResult');
// Playlist elements
export const $playlistSection = el('playlistSection');
export const $playlistSelect = el('playlistSelect');
export const $currentPlaylist = el('currentPlaylist');
export const $playlistInfo = el('playlistInfo');
export function showLobby() {
$lobby.classList.remove('hidden');
$room.classList.add('hidden');
}
export function showRoom() {
$lobby.classList.add('hidden');
$room.classList.remove('hidden');
}

View File

@@ -1,228 +0,0 @@
import {
$answerResult,
$audio,
$guessArtist,
$guessTitle,
$npArtist,
$npTitle,
$npYear,
} from './dom.js';
import { state } from './state.js';
import { cacheLastRoomId, cacheSessionId, sendMsg } from './ws.js';
import { renderRoom } from './render.js';
import { applySync } from './audio.js';
function updatePlayerIdFromRoom(r) {
try {
if (r?.players?.length === 1) {
const only = r.players[0];
if (only && only.id && only.id !== state.playerId) {
state.playerId = only.id;
try {
localStorage.setItem('playerId', only.id);
} catch {}
}
}
} catch {}
}
function shortName(id) {
if (!id) return '-';
const p = state.room?.players.find((x) => x.id === id);
return p ? p.name : id.slice(0, 4);
}
export function handleConnected(msg) {
state.playerId = msg.playerId;
try {
if (msg.playerId) localStorage.setItem('playerId', msg.playerId);
} catch {}
if (msg.sessionId) {
const existing = localStorage.getItem('sessionId');
if (!existing) cacheSessionId(msg.sessionId);
}
// lazy import to avoid cycle
import('./session.js').then(({ reusePlayerName, reconnectLastRoom }) => {
reusePlayerName();
reconnectLastRoom();
});
if (state.room) {
try {
updatePlayerIdFromRoom(state.room);
renderRoom(state.room);
} catch {}
}
}
export function handleRoomUpdate(msg) {
if (msg?.room?.id) cacheLastRoomId(msg.room.id);
const r = msg.room;
updatePlayerIdFromRoom(r);
renderRoom(r);
}
export function handlePlayTrack(msg) {
const t = msg.track;
state.lastTrack = t;
state.revealed = false;
$npTitle.textContent = '???';
$npArtist.textContent = '';
$npYear.textContent = '';
if ($guessTitle) $guessTitle.value = '';
if ($guessArtist) $guessArtist.value = '';
if ($answerResult) {
$answerResult.textContent = '';
$answerResult.className = 'mt-1 text-sm';
}
try {
$audio.preload = 'auto';
} catch {}
$audio.src = t.url;
const pf = document.getElementById('progressFill');
if (pf) pf.style.width = '0%';
const rd = document.getElementById('recordDisc');
if (rd) {
rd.classList.remove('spin-record');
rd.src = '/hitstar.png';
}
const { startAt, serverNow } = msg;
const now = Date.now();
const offsetMs = startAt - serverNow;
const localStart = now + offsetMs;
const delay = Math.max(0, localStart - now);
setTimeout(() => {
$audio.currentTime = 0;
$audio.play().catch(() => {});
const disc = document.getElementById('recordDisc');
if (disc) disc.classList.add('spin-record');
}, delay);
if (state.room) renderRoom(state.room);
}
export function handleSync(msg) {
applySync(msg.startAt, msg.serverNow);
}
export function handleControl(msg) {
const { action, startAt, serverNow } = msg;
if (action === 'pause') {
$audio.pause();
const disc = document.getElementById('recordDisc');
if (disc) disc.classList.remove('spin-record');
$audio.playbackRate = 1.0;
} else if (action === 'play') {
if (startAt && serverNow) {
const now = Date.now();
const elapsed = (now - startAt) / 1000;
$audio.currentTime = Math.max(0, elapsed);
}
$audio.play().catch(() => {});
const disc = document.getElementById('recordDisc');
if (disc) disc.classList.add('spin-record');
}
}
export function handleReveal(msg) {
const { result, track } = msg;
$npTitle.textContent = track.title || track.id || 'Track';
$npArtist.textContent = track.artist ? ` ${track.artist}` : '';
$npYear.textContent = track.year ? ` (${track.year})` : '';
state.revealed = true;
const $rb = document.getElementById('revealBanner');
if ($rb) {
if (result.correct) {
$rb.textContent = 'Richtig!';
$rb.className =
'inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium';
} else {
$rb.textContent = 'Falsch!';
$rb.className =
'inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium';
}
}
const $placeArea = document.getElementById('placeArea');
if ($placeArea) $placeArea.classList.add('hidden');
const rd = document.getElementById('recordDisc');
if (rd && track?.file) {
// Use track.file instead of track.id to include playlist folder prefix
const coverUrl = `/cover/${encodeURIComponent(track.file)}`;
const img = new Image();
img.onload = () => {
rd.src = coverUrl;
};
img.onerror = () => {
/* keep default logo */
};
img.src = `${coverUrl}?t=${Date.now()}`;
}
}
export function handleGameEnded(msg) {
alert(`Gewinner: ${shortName(msg.winner)}`);
}
export function onMessage(ev) {
const msg = JSON.parse(ev.data);
switch (msg.type) {
case 'resume_result': {
if (msg.ok) {
if (msg.playerId) {
state.playerId = msg.playerId;
try {
localStorage.setItem('playerId', msg.playerId);
} catch {}
}
const code = msg.roomId || state.room?.id || localStorage.getItem('lastRoomId');
if (code) sendMsg({ type: 'join_room', code });
if (state.room) {
try {
renderRoom(state.room);
} catch {}
}
} else {
const code = state.room?.id || localStorage.getItem('lastRoomId');
if (code) sendMsg({ type: 'join_room', code });
}
return;
}
case 'connected':
return handleConnected(msg);
case 'room_update':
return handleRoomUpdate(msg);
case 'play_track':
return handlePlayTrack(msg);
case 'sync':
return handleSync(msg);
case 'control':
return handleControl(msg);
case 'reveal':
return handleReveal(msg);
case 'game_ended':
return handleGameEnded(msg);
case 'answer_result': {
if ($answerResult) {
if (!msg.ok) {
$answerResult.textContent = '⛔ Eingabe ungültig oder gerade nicht möglich';
$answerResult.className = 'mt-1 text-sm text-rose-600';
} else {
const okBoth = !!(msg.correctTitle && msg.correctArtist);
const parts = [];
parts.push(msg.correctTitle ? 'Titel ✓' : 'Titel ✗');
parts.push(msg.correctArtist ? 'Künstler ✓' : 'Künstler ✗');
let coin = '';
if (msg.awarded) coin = ' +1 Token';
else if (msg.alreadyAwarded) coin = ' (bereits erhalten)';
$answerResult.textContent = `${parts.join(' · ')}${coin}`;
$answerResult.className = okBoth
? 'mt-1 text-sm text-emerald-600'
: 'mt-1 text-sm text-amber-600';
}
}
return;
}
default:
return;
}
}

View File

@@ -1,257 +0,0 @@
import {
$audio,
$answerResult,
$copyRoomCode,
$createRoom,
$guessArtist,
$guessTitle,
$joinRoom,
$lobby,
$nameDisplay,
$nameLobby,
$nextBtn,
$pauseBtn,
$placeBtn,
$readyChk,
$room,
$roomCode,
$roomId,
$slotSelect,
$startGame,
$leaveRoom,
$playBtn,
$volumeSlider,
$saveName,
} from './dom.js';
import { state } from './state.js';
import { initAudioUI, stopAudioPlayback } from './audio.js';
import { sendMsg } from './ws.js';
import { showToast, wire } from './utils.js';
export function wireUi() {
initAudioUI();
// --- Name autosave helpers
let nameDebounce;
const SAVE_DEBOUNCE_MS = 800;
// Button was removed; no state management needed.
function saveNameIfChanged(raw) {
const name = (raw || '').trim();
if (!name) return;
try {
const prev = localStorage.getItem('playerName') || '';
if (prev === name) return; // no-op
localStorage.setItem('playerName', name);
if ($nameDisplay) $nameDisplay.textContent = name;
sendMsg({ type: 'set_name', name });
showToast('Name gespeichert!');
} catch {
// best-effort
}
}
// Manual save button
if ($saveName) {
wire($saveName, 'click', () => {
if (nameDebounce) {
clearTimeout(nameDebounce);
nameDebounce = null;
}
const val = ($nameLobby?.value || '').trim();
if (!val) {
showToast('⚠️ Bitte gib einen Namen ein!');
return;
}
const prev = localStorage.getItem('playerName') || '';
if (prev === val) {
showToast('✓ Name bereits gespeichert!');
return;
}
saveNameIfChanged(val);
});
}
// Autosave on input with debounce
if ($nameLobby) {
wire($nameLobby, 'input', () => {
if (nameDebounce) clearTimeout(nameDebounce);
const val = ($nameLobby.value || '').trim();
if (!val) return;
nameDebounce = setTimeout(() => {
saveNameIfChanged($nameLobby.value);
nameDebounce = null;
}, SAVE_DEBOUNCE_MS);
});
// Save immediately on blur
wire($nameLobby, 'blur', () => {
if (nameDebounce) {
clearTimeout(nameDebounce);
nameDebounce = null;
}
const val = ($nameLobby.value || '').trim();
if (val) saveNameIfChanged(val);
});
// Save on Enter
wire($nameLobby, 'keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (nameDebounce) {
clearTimeout(nameDebounce);
nameDebounce = null;
}
const val = ($nameLobby.value || '').trim();
if (val) saveNameIfChanged(val);
}
});
}
wire($createRoom, 'click', () => sendMsg({ type: 'create_room' }));
wire($joinRoom, 'click', () => {
const code = $roomCode.value.trim();
if (code) sendMsg({ type: 'join_room', code });
});
wire($leaveRoom, 'click', () => {
sendMsg({ type: 'leave_room' });
try {
localStorage.removeItem('playerId');
localStorage.removeItem('sessionId');
localStorage.removeItem('dashboardHintSeen');
localStorage.removeItem('lastRoomId');
} catch {}
stopAudioPlayback();
state.room = null;
if ($nameLobby) {
try {
const storedName = localStorage.getItem('playerName') || '';
$nameLobby.value = storedName;
} catch {
$nameLobby.value = '';
}
}
if ($nameDisplay) $nameDisplay.textContent = '';
if ($readyChk) {
try {
$readyChk.checked = false;
} catch {}
}
$lobby.classList.remove('hidden');
$room.classList.add('hidden');
});
wire($startGame, 'click', () => {
// Validate playlist selection before starting
if (!state.room?.state?.playlist) {
showToast('⚠️ Bitte wähle zuerst eine Playlist aus!');
return;
}
sendMsg({ type: 'start_game' });
});
wire($readyChk, 'change', (e) => {
const val = !!e.target.checked;
state.pendingReady = val;
sendMsg({ type: 'set_ready', ready: val });
});
// Playlist selection
const $playlistSelect = document.getElementById('playlistSelect');
if ($playlistSelect) {
wire($playlistSelect, 'change', (e) => {
const playlistId = e.target.value;
sendMsg({ type: 'set_playlist', playlist: playlistId });
});
}
wire($placeBtn, 'click', () => {
const slot = parseInt($slotSelect.value, 10);
sendMsg({ type: 'place_guess', slot });
});
wire($playBtn, 'click', () => sendMsg({ type: 'player_control', action: 'play' }));
wire($pauseBtn, 'click', () => sendMsg({ type: 'player_control', action: 'pause' }));
wire($nextBtn, 'click', () => sendMsg({ type: 'next_track' }));
if ($volumeSlider && $audio) {
try {
$volumeSlider.value = String($audio.volume ?? 1);
} catch {}
$volumeSlider.addEventListener('input', () => {
$audio.volume = parseFloat($volumeSlider.value);
});
}
if ($copyRoomCode) {
$copyRoomCode.style.display = 'inline-block';
wire($copyRoomCode, 'click', () => {
if (state.room?.id) {
navigator.clipboard.writeText(state.room.id).then(() => {
$copyRoomCode.textContent = '✔️';
showToast('Code kopiert!');
setTimeout(() => {
$copyRoomCode.textContent = '📋';
}, 1200);
});
}
});
}
if ($roomId) {
wire($roomId, 'click', () => {
if (state.room?.id) {
navigator.clipboard.writeText(state.room.id).then(() => {
$roomId.title = 'Kopiert!';
showToast('Code kopiert!');
setTimeout(() => {
$roomId.title = 'Klicken zum Kopieren';
}, 1200);
});
}
});
$roomId.style.cursor = 'pointer';
}
const form = document.getElementById('answerForm');
if (form) {
form.addEventListener('submit', (e) => {
e.preventDefault();
const title = ($guessTitle?.value || '').trim();
const artist = ($guessArtist?.value || '').trim();
if (!title || !artist) {
if ($answerResult) {
$answerResult.textContent = 'Bitte Titel und Künstler eingeben';
$answerResult.className = 'mt-1 text-sm text-amber-600';
}
return;
}
sendMsg({ type: 'submit_answer', guess: { title, artist } });
});
}
// Dashboard one-time hint
const dashboard = document.getElementById('dashboard');
const dashboardHint = document.getElementById('dashboardHint');
if (dashboard && dashboardHint) {
try {
const seen = localStorage.getItem('dashboardHintSeen');
if (!seen) {
dashboardHint.classList.remove('hidden');
const hide = () => {
dashboardHint.classList.add('hidden');
try {
localStorage.setItem('dashboardHintSeen', '1');
} catch {}
dashboard.removeEventListener('toggle', hide);
dashboard.removeEventListener('click', hide);
};
dashboard.addEventListener('toggle', hide);
dashboard.addEventListener('click', hide, { once: true });
setTimeout(() => {
if (!localStorage.getItem('dashboardHintSeen')) hide();
}, 6000);
}
} catch {}
}
}

22
src/server-deno/.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
# .gitignore for Deno server
# Environment variables
.env
.env.local
# Deno cache
.deno/
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Test coverage
coverage/

View File

@@ -0,0 +1,321 @@
# Backend Comparison Checklist
Use this checklist to verify the new backend has feature parity with the old one.
## ✅ Core Functionality
### Game Flow
- [ ] Create room
- [ ] Join room
- [ ] Leave room
- [ ] Set player name
- [ ] Ready up in lobby
- [ ] Start game
- [ ] Play tracks
- [ ] Guess title
- [ ] Guess artist
- [ ] Guess year
- [ ] Place cards in timeline
- [ ] Win condition (reach goal)
- [ ] Game end
- [ ] Multiple rounds
### Player Management
- [ ] Generate player ID
- [ ] Generate session ID
- [ ] Session resumption
- [ ] Player disconnect handling
- [ ] Player reconnect handling
- [ ] Spectator mode
- [ ] Kick player (host only)
- [ ] Host transfer on leave
### Room Features
- [ ] Room ID generation
- [ ] Room listing (if implemented)
- [ ] Room deletion when empty
- [ ] Multiple rooms simultaneously
- [ ] Turn order management
- [ ] Ready status tracking
### Playlist System
- [ ] Default playlist
- [ ] Custom playlists (subdirectories)
- [ ] Playlist selection
- [ ] List available playlists
- [ ] Track loading
- [ ] Track metadata (title, artist, year)
- [ ] Shuffle deck
- [ ] Draw next track
### Audio Streaming
- [ ] Token generation
- [ ] Token expiration (10 min)
- [ ] Stream audio by token
- [ ] HTTP HEAD support
- [ ] HTTP Range support (seeking)
- [ ] .opus preference
- [ ] Fallback to other formats
- [ ] Cover art extraction
- [ ] Cover art caching
### Answer Checking
- [ ] Fuzzy title matching
- [ ] Fuzzy artist matching
- [ ] Year matching (±1 year)
- [ ] Diacritics normalization
- [ ] Case insensitive
- [ ] Remove remaster info
- [ ] Remove parentheticals
- [ ] Artist featuring/and/& handling
- [ ] Score calculation
- [ ] Partial credit
### State Management
- [ ] Timeline tracking per player
- [ ] Token/coin system
- [ ] Current guesser tracking
- [ ] Turn order
- [ ] Game phase (guess/reveal)
- [ ] Pause/resume
- [ ] Track position tracking
- [ ] Last result tracking
- [ ] Awarded coins this round
### Real-Time Sync
- [ ] WebSocket connection
- [ ] Time synchronization
- [ ] Sync timer (1 second)
- [ ] Track start time broadcast
- [ ] Room state broadcast
- [ ] Guess result broadcast
- [ ] Game end broadcast
## ✅ HTTP API Endpoints
### Playlists
- [ ] GET `/api/playlists` - List all playlists
- Returns: `{ ok, playlists: [{ id, name, trackCount }] }`
### Tracks
- [ ] GET `/api/tracks?playlist=<id>` - Get playlist tracks
- Returns: `{ ok, tracks: [...], playlist }`
### Years
- [ ] GET `/api/reload-years?playlist=<id>` - Reload years index
- Returns: `{ ok, count, playlist }`
### Audio
- [ ] HEAD `/audio/t/:token` - Check audio availability
- [ ] GET `/audio/t/:token` - Stream audio with token
- Supports Range header
- Returns appropriate status (200/206/404/416)
### Cover Art
- [ ] GET `/cover/:name` - Get cover art
- Returns image with correct MIME type
- Handles missing covers gracefully
- Tries alternative file extensions
### Static Files
- [ ] Serve public directory
- [ ] SPA routing support (fallback to index.html)
## ✅ WebSocket Events
### Client → Server
- [ ] `resume` - Resume session
- [ ] `create_room` - Create new room
- [ ] `join_room` - Join existing room
- [ ] `leave_room` - Leave current room
- [ ] `set_name` - Change player name
- [ ] `ready` - Toggle ready status
- [ ] `select_playlist` - Choose playlist (host only)
- [ ] `start_game` - Begin game (host only)
- [ ] `guess` - Submit guess
- [ ] `pause` - Pause game
- [ ] `resume_play` - Resume game
- [ ] `skip_track` - Skip current track
- [ ] `set_spectator` - Toggle spectator mode
- [ ] `kick_player` - Kick player (host only)
### Server → Client
- [ ] `connected` - Connection established
- [ ] `resume_result` - Resume attempt result
- [ ] `room_created` - Room created successfully
- [ ] `room_joined` - Joined room successfully
- [ ] `room_update` - Room state changed
- [ ] `play_track` - New track to play
- [ ] `guess_result` - Guess outcome
- [ ] `game_ended` - Game finished
- [ ] `sync` - Time synchronization
- [ ] `error` - Error occurred
## ✅ Data Structures
### Track
```typescript
{ id, file, title, artist, year, url? }
```
### Player
```typescript
{ id, sessionId, name, connected, roomId, spectator? }
```
### Room
```typescript
{
id, name, hostId,
players: [{ id, name, connected, ready, spectator }],
state: GameState
}
```
### GameState
```typescript
{
status, phase, turnOrder, currentGuesser,
currentTrack, timeline, tokens, ready, spectators,
lastResult, trackStartAt, paused, pausedPosSec,
goal, playlist, awardedThisRound
}
```
## ✅ Security Features
- [ ] Path traversal protection
- [ ] Token-based audio URLs
- [ ] Short-lived tokens (expiration)
- [ ] Input validation
- [ ] CORS configuration
- [ ] No filename exposure
- [ ] Deno permissions
## ✅ Performance Features
- [ ] LRU cache for tokens
- [ ] LRU cache for cover art
- [ ] Batch metadata processing
- [ ] Streaming with range support
- [ ] File deduplication (.opus preference)
- [ ] Memory-efficient streaming
## ✅ Configuration
- [ ] PORT environment variable
- [ ] HOST environment variable
- [ ] DATA_DIR configuration
- [ ] PUBLIC_DIR configuration
- [ ] TOKEN_TTL_MS configuration
- [ ] LOG_LEVEL configuration
- [ ] CORS_ORIGIN configuration
- [ ] AUDIO_DEBUG_NAMES flag
## ✅ Error Handling
- [ ] Invalid room ID
- [ ] Player not found
- [ ] Session not found
- [ ] File not found
- [ ] Invalid path
- [ ] Parse errors
- [ ] Network errors
- [ ] Validation errors
- [ ] Graceful degradation
## ✅ Logging
- [ ] Connection events
- [ ] Disconnection events
- [ ] Room creation
- [ ] Room deletion
- [ ] Game start
- [ ] Game end
- [ ] Player actions
- [ ] Errors
- [ ] HTTP requests
- [ ] Configurable log level
## Testing Checklist
### Manual Testing
- [ ] Start server successfully
- [ ] Create room
- [ ] Join room from another tab
- [ ] Set player names
- [ ] Select playlist
- [ ] Ready up all players
- [ ] Start game
- [ ] Play through full game
- [ ] Guess correctly
- [ ] Guess incorrectly
- [ ] Win game
- [ ] Pause/resume
- [ ] Skip track
- [ ] Disconnect and reconnect
- [ ] Multiple rooms simultaneously
### API Testing
```bash
# Playlists
curl http://localhost:5173/api/playlists
# Tracks
curl http://localhost:5173/api/tracks?playlist=default
# Years
curl http://localhost:5173/api/reload-years?playlist=default
# Audio (need token)
curl -I http://localhost:5173/audio/t/TOKEN_HERE
```
### Load Testing
- [ ] Multiple concurrent connections
- [ ] Multiple rooms active
- [ ] High frequency guesses
- [ ] Large playlists
- [ ] Memory usage stable
- [ ] CPU usage reasonable
## Migration Steps
1. **Preparation**
- [ ] Read QUICK_START.md
- [ ] Read MIGRATION_GUIDE.md
- [ ] Review PROJECT_SUMMARY.md
2. **Setup**
- [ ] Install Deno 2
- [ ] Copy .env.example to .env
- [ ] Configure environment variables
- [ ] Verify data directory
3. **Testing**
- [ ] Run `deno task dev`
- [ ] Test all features manually
- [ ] Compare with old backend
- [ ] Run test suite
- [ ] Check performance
4. **Deployment**
- [ ] Stop old backend
- [ ] Start new backend
- [ ] Monitor logs
- [ ] Verify all features
- [ ] Have rollback plan ready
## Notes
- All checkboxes should be ✅ before considering migration complete
- Test thoroughly in development before production deployment
- Keep old backend as backup during transition
- Monitor performance and logs closely
- Report any issues or discrepancies
## Status Legend
- [ ] Not tested
- [✓] Tested and working
- [✗] Tested and failing
- [~] Partially working

View File

@@ -0,0 +1,317 @@
/**
* Answer checking service for fuzzy matching of title, artist, and year guesses
* Based on the original answerCheck.js logic
*/
export interface ScoreResult {
score: number;
match: boolean;
}
/**
* Answer checking service
*/
export class AnswerCheckService {
/**
* Strip diacritics from string
*/
private stripDiacritics(str: string): string {
return str
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '');
}
/**
* Normalize common terms
*/
private normalizeCommon(str: string): string {
let normalized = this.stripDiacritics(str)
.toLowerCase()
// Normalize common contractions before removing punctuation
.replace(/\bcan't\b/g, 'cant')
.replace(/\bwon't\b/g, 'wont')
.replace(/\bdon't\b/g, 'dont')
.replace(/\bdidn't\b/g, 'didnt')
.replace(/\bisn't\b/g, 'isnt')
.replace(/\baren't\b/g, 'arent')
.replace(/\bwasn't\b/g, 'wasnt')
.replace(/\bweren't\b/g, 'werent')
.replace(/\bhasn't\b/g, 'hasnt')
.replace(/\bhaven't\b/g, 'havent')
.replace(/\bhadn't\b/g, 'hadnt')
.replace(/\bshouldn't\b/g, 'shouldnt')
.replace(/\bwouldn't\b/g, 'wouldnt')
.replace(/\bcouldn't\b/g, 'couldnt')
.replace(/\bmustn't\b/g, 'mustnt')
.replace(/\bi'm\b/g, 'im')
.replace(/\byou're\b/g, 'youre')
.replace(/\bhe's\b/g, 'hes')
.replace(/\bshe's\b/g, 'shes')
.replace(/\bit's\b/g, 'its')
.replace(/\bwe're\b/g, 'were')
.replace(/\bthey're\b/g, 'theyre')
.replace(/\bi've\b/g, 'ive')
.replace(/\byou've\b/g, 'youve')
.replace(/\bwe've\b/g, 'weve')
.replace(/\bthey've\b/g, 'theyve')
.replace(/\bi'll\b/g, 'ill')
.replace(/\byou'll\b/g, 'youll')
.replace(/\bhe'll\b/g, 'hell')
.replace(/\bshe'll\b/g, 'shell')
.replace(/\bwe'll\b/g, 'well')
.replace(/\bthey'll\b/g, 'theyll')
.replace(/\bthat's\b/g, 'thats')
.replace(/\bwho's\b/g, 'whos')
.replace(/\bwhat's\b/g, 'whats')
.replace(/\bwhere's\b/g, 'wheres')
.replace(/\bwhen's\b/g, 'whens')
.replace(/\bwhy's\b/g, 'whys')
.replace(/\bhow's\b/g, 'hows')
.replace(/\s*(?:&|and|x|×|with|vs\.?|feat\.?|featuring)\s*/g, ' ')
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
return normalized;
}
/**
* Clean title-specific noise (remasters, edits, etc.)
*/
private cleanTitleNoise(raw: string): string {
let s = raw;
// Remove common parenthetical annotations
s = s.replace(/\(([^)]*remaster[^)]*)\)/gi, '');
s = s.replace(/\(([^)]*radio edit[^)]*)\)/gi, '');
s = s.replace(/\(([^)]*edit[^)]*)\)/gi, '');
s = s.replace(/\(([^)]*version[^)]*)\)/gi, '');
s = s.replace(/\(([^)]*live[^)]*)\)/gi, '');
s = s.replace(/\(([^)]*mono[^)]*|[^)]*stereo[^)]*)\)/gi, '');
// Remove standalone noise words
s = s.replace(/\b(remaster(?:ed)?(?: \d{2,4})?|radio edit|single version|original mix|version|live)\b/gi, '');
return s;
}
/**
* Strip optional segments (parentheses, quotes, brackets)
*/
private stripOptionalSegments(raw: string): string {
let s = raw;
s = s.replace(/"[^"]*"/g, ' '); // Remove quoted segments
s = s.replace(/\([^)]*\)/g, ' '); // Remove parenthetical
s = s.replace(/\[[^\]]*\]/g, ' '); // Remove brackets
return s;
}
/**
* Normalize title for comparison
*/
private normalizeTitle(str: string): string {
return this.normalizeCommon(this.cleanTitleNoise(str));
}
/**
* Normalize title with optional segments removed
*/
private normalizeTitleBaseOptional(str: string): string {
return this.normalizeCommon(this.stripOptionalSegments(this.cleanTitleNoise(str)));
}
/**
* Normalize artist for comparison
*/
private normalizeArtist(str: string): string {
return this.normalizeCommon(str)
.replace(/\bthe\b/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Tokenize string
*/
private tokenize(str: string): string[] {
return str ? str.split(' ').filter(Boolean) : [];
}
/**
* Create token set
*/
private tokenSet(str: string): Set<string> {
return new Set(this.tokenize(str));
}
/**
* Calculate Jaccard similarity
*/
private jaccard(a: string, b: string): number {
const setA = this.tokenSet(a);
const setB = this.tokenSet(b);
if (setA.size === 0 && setB.size === 0) return 1;
let intersection = 0;
for (const token of setA) {
if (setB.has(token)) intersection++;
}
const union = setA.size + setB.size - intersection;
return union ? intersection / union : 0;
}
/**
* Calculate Levenshtein distance
*/
private levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
if (!m) return n;
if (!n) return m;
const dp = new Array(n + 1).fill(0);
for (let j = 0; j <= n; j++) {
dp[j] = j;
}
for (let i = 1; i <= m; i++) {
let prev = dp[0];
dp[0] = i;
for (let j = 1; j <= n; j++) {
const temp = dp[j];
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost);
prev = temp;
}
}
return dp[n];
}
/**
* Calculate similarity ratio based on Levenshtein distance
*/
private simRatio(a: string, b: string): number {
if (!a && !b) return 1;
if (!a || !b) return 0;
const dist = this.levenshtein(a, b);
const maxLen = Math.max(a.length, b.length);
return maxLen ? 1 - dist / maxLen : 1;
}
/**
* Split artists string
*/
splitArtists(raw: string): string[] {
return raw
.split(/[,&+]|(?:\s+(?:feat\.?|featuring|with|vs\.?|and|x)\s+)/i)
.map((s) => s.trim())
.filter(Boolean);
}
/**
* Score title guess
*/
scoreTitle(guess: string, correct: string): ScoreResult {
if (!guess || !correct) {
return { score: 0, match: false };
}
const g = this.normalizeTitle(guess);
const c = this.normalizeTitle(correct);
// Exact match
if (g === c) {
return { score: 1.0, match: true };
}
// Try without optional segments
const gOpt = this.normalizeTitleBaseOptional(guess);
const cOpt = this.normalizeTitleBaseOptional(correct);
if (gOpt === cOpt && gOpt.length > 0) {
return { score: 0.98, match: true };
}
// Fuzzy matching
const jac = this.jaccard(g, c);
const sim = this.simRatio(g, c);
const score = 0.6 * jac + 0.4 * sim;
// Accept if score >= 0.6 (softened threshold)
return { score, match: score >= 0.6 };
}
/**
* Score artist guess
*/
scoreArtist(guess: string, correct: string): ScoreResult {
if (!guess || !correct) {
return { score: 0, match: false };
}
const guessParts = this.splitArtists(guess);
const correctParts = this.splitArtists(correct);
const gNorm = guessParts.map((p) => this.normalizeArtist(p));
const cNorm = correctParts.map((p) => this.normalizeArtist(p));
// Check if any guess part matches any correct part
let bestScore = 0;
for (const gPart of gNorm) {
for (const cPart of cNorm) {
if (gPart === cPart) {
return { score: 1.0, match: true };
}
const jac = this.jaccard(gPart, cPart);
const sim = this.simRatio(gPart, cPart);
const score = 0.6 * jac + 0.4 * sim;
bestScore = Math.max(bestScore, score);
}
}
// Accept if score >= 0.6 (softened threshold)
return { score: bestScore, match: bestScore >= 0.6 };
}
/**
* Score year guess
*/
scoreYear(guess: number | string, correct: number | null): ScoreResult {
if (correct === null) {
return { score: 0, match: false };
}
const guessNum = typeof guess === 'string' ? parseInt(guess, 10) : guess;
if (isNaN(guessNum)) {
return { score: 0, match: false };
}
if (guessNum === correct) {
return { score: 1.0, match: true };
}
const diff = Math.abs(guessNum - correct);
// Accept within 1 year
if (diff <= 1) {
return { score: 0.9, match: true };
}
// Within 2 years - partial credit but no match
if (diff <= 2) {
return { score: 0.7, match: false };
}
return { score: 0, match: false };
}
}

View File

@@ -0,0 +1,383 @@
import type { GuessResult, ID, Track } from '../domain/types.ts';
import { GameStatus } from '../domain/types.ts';
import type { RoomModel } from '../domain/models/mod.ts';
import { AudioStreamingService } from '../infrastructure/mod.ts';
import { TrackService } from './TrackService.ts';
import { AnswerCheckService } from './AnswerCheckService.ts';
import { logger } from '../shared/logger.ts';
import { ValidationError } from '../shared/errors.ts';
/**
* Game service for managing game logic and flow
*/
export class GameService {
constructor(
private readonly trackService: TrackService,
private readonly audioStreaming: AudioStreamingService,
private readonly answerCheck: AnswerCheckService,
) { }
/**
* Start a game in a room
*/
async startGame(room: RoomModel): Promise<void> {
if (room.state.status === ('playing' as GameStatus)) {
throw new ValidationError('Game already in progress');
}
if (!room.state.playlist) {
throw new ValidationError('No playlist selected');
}
if (!room.state.areAllReady()) {
throw new ValidationError('Not all players are ready');
}
// Load shuffled deck
const tracks = await this.trackService.loadShuffledDeck(room.state.playlist);
if (tracks.length === 0) {
throw new ValidationError('No tracks available in selected playlist');
}
room.setDeck(tracks);
// Initialize game state
const playerIds = Array.from(room.players.keys()).filter(
(id) => !room.state.spectators[id]
);
room.state.startGame(playerIds);
logger.info(`Game started in room ${room.id} with ${playerIds.length} players`);
}
/**
* Draw next track and prepare for guessing
*/
async drawNextTrack(room: RoomModel): Promise<Track | null> {
const track = room.drawTrack();
if (!track) {
// No more tracks - end game
room.state.endGame();
return null;
}
// Create streaming token
try {
const token = await this.audioStreaming.createAudioToken(track.file);
track.url = `/audio/t/${token}`;
} catch (error) {
logger.error(`Failed to create audio token for ${track.file}: ${error}`);
// Fallback to name-based URL
track.url = `/audio/${encodeURIComponent(track.file)}`;
}
// Update game state
room.state.currentTrack = track;
room.state.resetRound();
room.state.trackStartAt = Date.now() + 800; // Small delay for sync
logger.info(`Track drawn in room ${room.id}: ${track.title} - ${track.artist}`);
return track;
}
/**
* Process a guess
*/
processGuess(
room: RoomModel,
playerId: ID,
guess: string,
type: 'title' | 'artist' | 'year',
): GuessResult {
const player = room.getPlayer(playerId);
const track = room.state.currentTrack;
if (!player || !track) {
throw new ValidationError('Invalid guess context');
}
let result: GuessResult;
switch (type) {
case 'title': {
const scoreResult = this.answerCheck.scoreTitle(guess, track.title);
result = {
playerId,
playerName: player.name,
guess,
correct: scoreResult.match,
type: 'title',
score: scoreResult.score,
answer: track.title,
};
break;
}
case 'artist': {
const scoreResult = this.answerCheck.scoreArtist(guess, track.artist);
result = {
playerId,
playerName: player.name,
guess,
correct: scoreResult.match,
type: 'artist',
score: scoreResult.score,
answer: track.artist,
};
break;
}
case 'year': {
const scoreResult = this.answerCheck.scoreYear(guess, track.year);
result = {
playerId,
playerName: player.name,
guess,
correct: scoreResult.match,
type: 'year',
score: scoreResult.score,
answer: track.year ? String(track.year) : 'Unknown',
};
break;
}
default:
throw new ValidationError('Invalid guess type');
}
// NOTE: This method is legacy and no longer awards tokens.
// Token awarding is now handled by:
// - checkTitleArtistGuess() for title+artist tokens
// - placeInTimeline() for placement tokens
room.state.lastResult = result;
return result;
}
/**
* Place a card in player's timeline
*/
placeCard(room: RoomModel, playerId: ID, year: number, position: number): boolean {
const player = room.getPlayer(playerId);
if (!player) {
throw new ValidationError('Player not found');
}
room.state.addToTimeline(playerId, year, position);
// Check for winner
if (room.state.hasPlayerWon(playerId)) {
room.state.endGame();
logger.info(`Player ${player.name} won in room ${room.id}!`);
return true;
}
return false;
}
/**
* Skip to next player's turn
*/
nextTurn(room: RoomModel): ID | null {
return room.state.nextTurn();
}
/**
* Pause game
*/
pauseGame(room: RoomModel): void {
if (!room.state.paused && room.state.trackStartAt) {
const elapsed = (Date.now() - room.state.trackStartAt) / 1000;
room.state.pausedPosSec = elapsed;
room.state.paused = true;
}
}
/**
* Resume game
*/
resumeGame(room: RoomModel): void {
if (room.state.paused) {
room.state.trackStartAt = Date.now() - (room.state.pausedPosSec * 1000);
room.state.paused = false;
}
}
/**
* End game
*/
endGame(room: RoomModel): void {
room.state.endGame();
logger.info(`Game ended in room ${room.id}`);
}
/**
* Get winner
*/
getWinner(room: RoomModel): ID | null {
return room.state.getWinner();
}
/**
* Check title and artist guess
*
* SCORING SYSTEM:
* - Tokens: Awarded ONLY for correctly guessing BOTH title AND artist
* - 1 token per round (can only get once per track)
* - Used as currency/bonus points
*
* - Score: Number of correctly placed tracks in timeline (timeline.length)
* - Increases only when placement is correct
* - This is the main win condition
*/
async checkTitleArtistGuess(
room: RoomModel,
playerId: ID,
guessTitle: string,
guessArtist: string
): Promise<{
titleCorrect: boolean;
artistCorrect: boolean;
awarded: boolean;
alreadyAwarded: boolean;
}> {
const track = room.state.currentTrack;
if (!track) {
throw new Error('No current track');
}
// Check if title+artist token already awarded this round
const alreadyAwarded = room.state.titleArtistAwardedThisRound[playerId] || false;
// Score the guesses
const titleResult = this.answerCheck.scoreTitle(guessTitle, track.title);
const artistResult = this.answerCheck.scoreArtist(guessArtist, track.artist);
const titleCorrect = titleResult.match;
const artistCorrect = artistResult.match;
const bothCorrect = titleCorrect && artistCorrect;
// Award 1 token if BOTH title and artist are correct, and not already awarded this round
let awarded = false;
if (bothCorrect && !alreadyAwarded) {
room.state.tokens[playerId] = (room.state.tokens[playerId] || 0) + 1;
room.state.titleArtistAwardedThisRound[playerId] = true;
awarded = true;
logger.info(
`Player ${playerId} correctly guessed title AND artist. Awarded 1 token for title+artist.`
);
}
return {
titleCorrect,
artistCorrect,
awarded,
alreadyAwarded: alreadyAwarded && bothCorrect,
};
}
/**
* Place track in timeline
*
* SCORING SYSTEM:
* - Correct placement: Adds track to timeline, increasing score (timeline.length)
* - Incorrect placement: Track is discarded, no score increase
* - NO tokens awarded here - tokens are only from title+artist guesses
*
* TOKEN PURCHASE:
* - If useTokens is true and player has >= 3 tokens, deduct 3 tokens and force correct placement
*/
async placeInTimeline(
room: RoomModel,
playerId: ID,
slot: number,
useTokens: boolean = false
): Promise<{ correct: boolean; tokensUsed: boolean }> {
const track = room.state.currentTrack;
if (!track) {
throw new Error('No current track');
}
const timeline = room.state.timeline[playerId] || [];
const n = timeline.length;
// Validate slot
if (slot < 0 || slot > n) {
slot = n;
}
logger.info(`Timeline before placement: ${JSON.stringify(timeline.map(t => ({ year: t.year, title: t.title })))}`);
logger.info(`Placing track: ${track.title} (${track.year}) at slot ${slot} of ${n}`);
// Check if player wants to use tokens to guarantee correct placement
let tokensUsed = false;
const TOKEN_COST = 3;
const playerTokens = room.state.tokens[playerId] || 0;
if (useTokens && playerTokens >= TOKEN_COST) {
// Deduct tokens and force correct placement
room.state.tokens[playerId] = playerTokens - TOKEN_COST;
tokensUsed = true;
logger.info(
`Player ${playerId} used ${TOKEN_COST} tokens to guarantee correct placement. Remaining tokens: ${room.state.tokens[playerId]}`
);
}
// Check if placement is correct
let correct = false;
if (tokensUsed) {
// Tokens used - placement is always correct
correct = true;
} else if (track.year != null) {
if (n === 0) {
correct = true; // First card is always correct
} else {
const leftYear = slot > 0 ? timeline[slot - 1]?.year : null;
const rightYear = slot < n ? timeline[slot]?.year : null;
// Allow equal years (>=, <=) so cards from the same year can be placed anywhere relative to each other
const leftOk = leftYear == null || track.year >= leftYear;
const rightOk = rightYear == null || track.year <= rightYear;
correct = leftOk && rightOk;
// Debug logging
logger.info(`Placement check - Track year: ${track.year}, Slot: ${slot}/${n}, Left: ${leftYear}, Right: ${rightYear}, LeftOk: ${leftOk}, RightOk: ${rightOk}, Correct: ${correct}`);
}
} else {
logger.warn(`Track has no year: ${track.title}`);
}
// Update timeline if correct (score is the timeline length)
if (correct) {
const newTimeline = [...timeline];
newTimeline.splice(slot, 0, {
trackId: track.id,
year: track.year,
title: track.title,
artist: track.artist,
});
room.state.timeline[playerId] = newTimeline;
// Score increases automatically (it's the timeline length)
// NO token awarded here - tokens are only from title+artist guesses
logger.info(
`Player ${playerId} correctly placed track in timeline. Score is now ${newTimeline.length}.`
);
} else {
// Discard the track
room.discard.push(track);
logger.info(
`Player ${playerId} incorrectly placed track. No score increase.`
);
}
return { correct, tokensUsed };
}
}

View File

@@ -0,0 +1,223 @@
import type { ID, Player, Room } from '../domain/types.ts';
import { PlayerModel, RoomModel } from '../domain/models/mod.ts';
import { generateShortId, generateUUID } from '../shared/utils.ts';
import { ConflictError, NotFoundError } from '../shared/errors.ts';
import { ROOM_ID_LENGTH } from '../shared/constants.ts';
import { logger } from '../shared/logger.ts';
/**
* Room service for managing game rooms and players
*/
export class RoomService {
private readonly rooms: Map<ID, RoomModel> = new Map();
private readonly players: Map<ID, PlayerModel> = new Map();
private readonly sessionToPlayer: Map<ID, ID> = new Map();
/**
* Create a new room with an existing player as host
*/
createRoomWithPlayer(player: PlayerModel, roomName?: string, goal = 10): { room: RoomModel } {
// Generate unique room ID
let roomId: string;
do {
roomId = generateShortId(ROOM_ID_LENGTH);
} while (this.rooms.has(roomId));
// Create room
const room = new RoomModel(roomId, roomName || `Room ${roomId}`, player, goal);
// Store room
this.rooms.set(roomId, room);
// Join player to room
player.joinRoom(roomId);
logger.info(`Room created: ${roomId} by player ${player.id} (${player.name})`);
return { room };
}
/**
* Create a new room (legacy method that creates a new player)
*/
createRoom(hostName: string, roomName?: string, goal = 10): { room: RoomModel; player: PlayerModel } {
// Create host player
const playerId = generateUUID();
const sessionId = generateUUID();
const player = new PlayerModel(playerId, sessionId, hostName);
// Store player
this.players.set(playerId, player);
this.sessionToPlayer.set(sessionId, playerId);
// Create room with this player
const { room } = this.createRoomWithPlayer(player, roomName, goal);
return { room, player };
}
/**
* Join an existing room with an existing player
*/
joinRoomWithPlayer(roomId: ID, player: PlayerModel): { room: RoomModel } {
const room = this.rooms.get(roomId);
if (!room) {
throw new NotFoundError('Room not found');
}
// Add to room
room.addPlayer(player);
// Join player to room
player.joinRoom(roomId);
logger.info(`Player ${player.id} (${player.name}) joined room ${roomId}`);
return { room };
}
/**
* Join an existing room (legacy method that creates a new player)
*/
joinRoom(roomId: ID, playerName: string): { room: RoomModel; player: PlayerModel } {
// Create player
const playerId = generateUUID();
const sessionId = generateUUID();
const player = new PlayerModel(playerId, sessionId, playerName);
// Store
this.players.set(playerId, player);
this.sessionToPlayer.set(sessionId, playerId);
// Join room with this player
const { room } = this.joinRoomWithPlayer(roomId, player);
return { room, player };
}
/**
* Leave room
*/
leaveRoom(playerId: ID): void {
const player = this.players.get(playerId);
if (!player || !player.roomId) {
return;
}
const room = this.rooms.get(player.roomId);
if (room) {
room.removePlayer(playerId);
// If room is empty, delete it
if (room.players.size === 0) {
this.rooms.delete(room.id);
logger.info(`Room ${room.id} deleted (empty)`);
} else if (room.hostId === playerId) {
// Transfer host if current host leaves
const newHost = room.getConnectedPlayers()[0];
if (newHost) {
room.transferHost(newHost.id);
logger.info(`Host transferred to ${newHost.id} in room ${room.id}`);
}
}
}
player.leaveRoom();
logger.info(`Player ${playerId} left room ${player.roomId}`);
}
/**
* Get room by ID
*/
getRoom(roomId: ID): RoomModel | undefined {
return this.rooms.get(roomId);
}
/**
* Get player by ID
*/
getPlayer(playerId: ID): PlayerModel | undefined {
return this.players.get(playerId);
}
/**
* Get player by session ID
*/
getPlayerBySession(sessionId: ID): PlayerModel | undefined {
const playerId = this.sessionToPlayer.get(sessionId);
return playerId ? this.players.get(playerId) : undefined;
}
/**
* Create a new player
*/
createPlayer(name?: string): { player: PlayerModel; sessionId: ID } {
const playerId = generateUUID();
const sessionId = generateUUID();
const player = new PlayerModel(playerId, sessionId, name);
this.players.set(playerId, player);
this.sessionToPlayer.set(sessionId, playerId);
return { player, sessionId };
}
/**
* Resume player session
*/
resumePlayer(sessionId: ID): PlayerModel | null {
return this.getPlayerBySession(sessionId) || null;
}
/**
* Update player connection status
*/
setPlayerConnected(playerId: ID, connected: boolean): void {
const player = this.players.get(playerId);
if (player) {
player.setConnected(connected);
}
}
/**
* Set player name
*/
setPlayerName(playerId: ID, name: string): void {
const player = this.players.get(playerId);
if (player) {
player.setName(name);
}
}
/**
* Get all rooms
*/
getAllRooms(): RoomModel[] {
return Array.from(this.rooms.values());
}
/**
* Get all players
*/
getAllPlayers(): PlayerModel[] {
return Array.from(this.players.values());
}
/**
* Clean up disconnected players (call periodically)
*/
cleanupDisconnected(timeout = 5 * 60 * 1000): void {
const now = Date.now();
for (const player of this.players.values()) {
if (!player.connected && player.roomId) {
// Could add timestamp tracking here
// For now, just log
logger.debug(`Disconnected player: ${player.id}`);
}
}
}
}

View File

@@ -0,0 +1,127 @@
import type { Playlist, Track } from '../domain/types.ts';
import { FileSystemService, MetadataService } from '../infrastructure/mod.ts';
import { AUDIO_EXTENSIONS } from '../shared/constants.ts';
import { logger } from '../shared/logger.ts';
import { shuffle } from '../shared/utils.ts';
/**
* Track service for managing playlists and tracks
*/
export class TrackService {
constructor(
private readonly fileSystem: FileSystemService,
private readonly metadata: MetadataService,
) { }
/**
* Get list of available playlists
*/
async getAvailablePlaylists(): Promise<Playlist[]> {
const playlists: Playlist[] = [];
const dataDir = this.fileSystem.getDataDir();
try {
// Check root directory for default playlist
const audioPattern = new RegExp(`(${AUDIO_EXTENSIONS.join('|').replace(/\./g, '\\.')})$`, 'i');
const rootFiles = await this.fileSystem.listFiles(dataDir, audioPattern);
if (rootFiles.length > 0) {
playlists.push({
id: 'default',
name: 'Default (Root Folder)',
trackCount: rootFiles.length,
});
}
// Check subdirectories
const subdirs = await this.fileSystem.listDirectories(dataDir);
for (const dir of subdirs) {
const dirPath = this.fileSystem.getPlaylistDir(dir);
const dirFiles = await this.fileSystem.listFiles(dirPath, audioPattern);
if (dirFiles.length > 0) {
playlists.push({
id: dir,
name: dir,
trackCount: dirFiles.length,
});
}
}
return playlists;
} catch (error) {
logger.error(`Error reading playlists: ${error}`);
return [];
}
}
/**
* Load tracks from a playlist
*/
async loadPlaylistTracks(playlistId: string = 'default'): Promise<Track[]> {
try {
return await this.metadata.loadTracksFromPlaylist(playlistId);
} catch (error) {
logger.error(`Error loading tracks from playlist ${playlistId}: ${error}`);
return [];
}
}
/**
* Load and shuffle a deck of tracks
*/
async loadShuffledDeck(playlistId: string = 'default'): Promise<Track[]> {
const tracks = await this.loadPlaylistTracks(playlistId);
return shuffle(tracks);
}
/**
* Reload years index for a playlist
*/
async reloadYearsIndex(playlistId: string = 'default'): Promise<{ count: number }> {
const yearsIndex = await this.metadata.loadYearsIndex(playlistId);
const count = Object.keys(yearsIndex).length;
logger.info(`Reloaded years index for playlist '${playlistId}': ${count} entries`);
return { count };
}
/**
* Preload all playlists into cache at startup
* This prevents slow loading when the first game starts
*/
async preloadAllPlaylists(): Promise<void> {
const startTime = Date.now();
logger.info('🎵 Starting playlist preload...');
try {
const playlists = await this.getAvailablePlaylists();
if (playlists.length === 0) {
logger.warn('⚠️ No playlists found to preload');
return;
}
logger.info(`📋 Found ${playlists.length} playlist(s) to preload`);
let totalTracks = 0;
for (const playlist of playlists) {
const playlistStart = Date.now();
const tracks = await this.loadPlaylistTracks(playlist.id);
const playlistDuration = Date.now() - playlistStart;
totalTracks += tracks.length;
logger.info(` ✓ Loaded playlist '${playlist.name}': ${tracks.length} tracks in ${playlistDuration}ms`);
}
const totalDuration = Date.now() - startTime;
logger.info(`✅ Playlist preload complete: ${totalTracks} total tracks from ${playlists.length} playlist(s) in ${totalDuration}ms`);
} catch (error) {
const duration = Date.now() - startTime;
logger.error(`❌ Playlist preload failed after ${duration}ms: ${error}`);
}
}
}

View File

@@ -0,0 +1,7 @@
/**
* Application layer exports
*/
export { TrackService } from './TrackService.ts';
export { RoomService } from './RoomService.ts';
export { GameService } from './GameService.ts';
export { AnswerCheckService } from './AnswerCheckService.ts';

File diff suppressed because it is too large Load Diff

50
src/server-deno/deno.json Normal file
View File

@@ -0,0 +1,50 @@
{
"compilerOptions": {
"lib": ["deno.window", "deno.unstable"],
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"tasks": {
"dev": "deno run --allow-net --allow-read --allow-env --allow-write --watch main.ts",
"start": "deno run --allow-net --allow-read --allow-env --allow-write main.ts",
"test": "deno test --allow-net --allow-read --allow-env --allow-write",
"test:watch": "deno test --allow-net --allow-read --allow-env --allow-write --watch",
"lint": "deno lint",
"fmt": "deno fmt",
"check": "deno check **/*.ts"
},
"fmt": {
"useTabs": false,
"lineWidth": 100,
"indentWidth": 2,
"semiColons": true,
"singleQuote": true,
"proseWrap": "preserve"
},
"lint": {
"rules": {
"tags": ["recommended"],
"exclude": ["no-explicit-any"]
}
},
"imports": {
"@oak/oak": "jsr:@oak/oak@^17.1.3",
"@std/path": "jsr:@std/path@^1.0.8",
"@std/fs": "jsr:@std/fs@^1.0.8",
"@std/log": "jsr:@std/log@^0.224.9",
"@std/crypto": "jsr:@std/crypto@^1.0.3",
"@std/encoding": "jsr:@std/encoding@^1.0.5",
"@std/uuid": "jsr:@std/uuid@^1.0.4",
"socket.io": "https://deno.land/x/socket_io@0.2.0/mod.ts",
"lru-cache": "npm:lru-cache@^11.0.0",
"music-metadata": "npm:music-metadata@^7.14.0"
}
}

396
src/server-deno/deno.lock generated Normal file
View File

@@ -0,0 +1,396 @@
{
"version": "5",
"specifiers": {
"jsr:@oak/commons@1": "1.0.1",
"jsr:@oak/oak@^17.1.3": "17.1.6",
"jsr:@std/assert@1": "1.0.15",
"jsr:@std/bytes@1": "1.0.6",
"jsr:@std/bytes@^1.0.6": "1.0.6",
"jsr:@std/crypto@1": "1.0.5",
"jsr:@std/crypto@^1.0.5": "1.0.5",
"jsr:@std/encoding@1": "1.0.10",
"jsr:@std/encoding@^1.0.10": "1.0.10",
"jsr:@std/encoding@^1.0.5": "1.0.10",
"jsr:@std/fmt@^1.0.5": "1.0.8",
"jsr:@std/fs@^1.0.11": "1.0.19",
"jsr:@std/fs@^1.0.8": "1.0.19",
"jsr:@std/http@1": "1.0.21",
"jsr:@std/internal@^1.0.10": "1.0.12",
"jsr:@std/internal@^1.0.9": "1.0.12",
"jsr:@std/io@~0.225.2": "0.225.2",
"jsr:@std/log@~0.224.9": "0.224.14",
"jsr:@std/media-types@1": "1.1.0",
"jsr:@std/path@1": "1.1.2",
"jsr:@std/path@^1.0.8": "1.1.2",
"jsr:@std/path@^1.1.1": "1.1.2",
"jsr:@std/uuid@^1.0.4": "1.0.9",
"npm:lru-cache@11": "11.2.2",
"npm:music-metadata@^7.14.0": "7.14.0",
"npm:path-to-regexp@^6.3.0": "6.3.0"
},
"jsr": {
"@oak/commons@1.0.1": {
"integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c",
"dependencies": [
"jsr:@std/assert",
"jsr:@std/bytes@1",
"jsr:@std/crypto@1",
"jsr:@std/encoding@1",
"jsr:@std/http",
"jsr:@std/media-types"
]
},
"@oak/oak@17.1.6": {
"integrity": "c7eef2eec733fba8e72b679bba3b8cf2fceccf5ef489a8b8fb43571908c0335d",
"dependencies": [
"jsr:@oak/commons",
"jsr:@std/assert",
"jsr:@std/bytes@1",
"jsr:@std/http",
"jsr:@std/media-types",
"jsr:@std/path@1",
"npm:path-to-regexp"
]
},
"@std/assert@1.0.15": {
"integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b"
},
"@std/bytes@1.0.6": {
"integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"
},
"@std/crypto@1.0.5": {
"integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40"
},
"@std/encoding@1.0.10": {
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
},
"@std/fmt@1.0.8": {
"integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7"
},
"@std/fs@1.0.19": {
"integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06",
"dependencies": [
"jsr:@std/internal@^1.0.9",
"jsr:@std/path@^1.1.1"
]
},
"@std/http@1.0.21": {
"integrity": "abb5c747651ee6e3ea6139858fd9b1810d2c97f53a5e6722f3b6d27a6d263edc",
"dependencies": [
"jsr:@std/encoding@^1.0.10"
]
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/io@0.225.2": {
"integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7"
},
"@std/log@0.224.14": {
"integrity": "257f7adceee3b53bb2bc86c7242e7d1bc59729e57d4981c4a7e5b876c808f05e",
"dependencies": [
"jsr:@std/fmt",
"jsr:@std/fs@^1.0.11",
"jsr:@std/io"
]
},
"@std/media-types@1.1.0": {
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
},
"@std/path@1.1.2": {
"integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038",
"dependencies": [
"jsr:@std/internal@^1.0.10"
]
},
"@std/uuid@1.0.9": {
"integrity": "44b627bf2d372fe1bd099e2ad41b2be41a777fc94e62a3151006895a037f1642",
"dependencies": [
"jsr:@std/bytes@^1.0.6",
"jsr:@std/crypto@^1.0.5"
]
}
},
"npm": {
"@tokenizer/token@0.3.0": {
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="
},
"abort-controller@3.0.0": {
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"dependencies": [
"event-target-shim"
]
},
"base64-js@1.5.1": {
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
"buffer@6.0.3": {
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"dependencies": [
"base64-js",
"ieee754"
]
},
"content-type@1.0.5": {
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="
},
"debug@4.4.3": {
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": [
"ms"
]
},
"event-target-shim@5.0.1": {
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
},
"events@3.3.0": {
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="
},
"file-type@16.5.4": {
"integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
"dependencies": [
"readable-web-to-node-stream",
"strtok3",
"token-types"
]
},
"ieee754@1.2.1": {
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
},
"lru-cache@11.2.2": {
"integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="
},
"media-typer@1.1.0": {
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="
},
"ms@2.1.3": {
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"music-metadata@7.14.0": {
"integrity": "sha512-xrm3w7SV0Wk+OythZcSbaI8mcr/KHd0knJieu8bVpaPfMv/Agz5EooCAPz3OR5hbYMiUG6dgAPKZKnMzV+3amA==",
"dependencies": [
"@tokenizer/token",
"content-type",
"debug",
"file-type",
"media-typer",
"strtok3",
"token-types"
]
},
"path-to-regexp@6.3.0": {
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="
},
"peek-readable@4.1.0": {
"integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="
},
"process@0.11.10": {
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="
},
"readable-stream@4.7.0": {
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"dependencies": [
"abort-controller",
"buffer",
"events",
"process",
"string_decoder"
]
},
"readable-web-to-node-stream@3.0.4": {
"integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==",
"dependencies": [
"readable-stream"
]
},
"safe-buffer@5.2.1": {
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
},
"string_decoder@1.3.0": {
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dependencies": [
"safe-buffer"
]
},
"strtok3@6.3.0": {
"integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
"dependencies": [
"@tokenizer/token",
"peek-readable"
]
},
"token-types@4.2.1": {
"integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
"dependencies": [
"@tokenizer/token",
"ieee754"
]
}
},
"remote": {
"https://deno.land/std@0.150.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
"https://deno.land/std@0.150.0/async/abortable.ts": "87aa7230be8360c24ad437212311c9e8d4328854baec27b4c7abb26e85515c06",
"https://deno.land/std@0.150.0/async/deadline.ts": "48ac998d7564969f3e6ec6b6f9bf0217ebd00239b1b2292feba61272d5dd58d0",
"https://deno.land/std@0.150.0/async/debounce.ts": "564273ef242bcfcda19a439132f940db8694173abffc159ea34f07d18fc42620",
"https://deno.land/std@0.150.0/async/deferred.ts": "bc18e28108252c9f67dfca2bbc4587c3cbf3aeb6e155f8c864ca8ecff992b98a",
"https://deno.land/std@0.150.0/async/delay.ts": "cbbdf1c87d1aed8edc7bae13592fb3e27e3106e0748f089c263390d4f49e5f6c",
"https://deno.land/std@0.150.0/async/mod.ts": "9852cd8ed897ab2d41a8fbee611d574e97898327db5c19d9d58e41126473f02c",
"https://deno.land/std@0.150.0/async/mux_async_iterator.ts": "5b4aca6781ad0f2e19ccdf1d1a1c092ccd3e00d52050d9c27c772658c8367256",
"https://deno.land/std@0.150.0/async/pool.ts": "ef9eb97b388543acbf0ac32647121e4dbe629236899586c4d4311a8770fbb239",
"https://deno.land/std@0.150.0/async/tee.ts": "bcfae0017ebb718cf4eef9e2420e8675d91cb1bcc0ed9b668681af6e6caad846",
"https://deno.land/std@0.150.0/bytes/bytes_list.ts": "aba5e2369e77d426b10af1de0dcc4531acecec27f9b9056f4f7bfbf8ac147ab4",
"https://deno.land/std@0.150.0/bytes/equals.ts": "3c3558c3ae85526f84510aa2b48ab2ad7bdd899e2e0f5b7a8ffc85acb3a6043a",
"https://deno.land/std@0.150.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf",
"https://deno.land/std@0.150.0/fmt/colors.ts": "6f9340b7fb8cc25a993a99e5efc56fe81bb5af284ff412129dd06df06f53c0b4",
"https://deno.land/std@0.150.0/fs/exists.ts": "cb734d872f8554ea40b8bff77ad33d4143c1187eac621a55bf37781a43c56f6d",
"https://deno.land/std@0.150.0/http/server.ts": "0b0a9f3abfcfecead944b31ee9098a0c11a59b0495bf873ee200eb80e7441483",
"https://deno.land/std@0.150.0/io/buffer.ts": "bd0c4bf53db4b4be916ca5963e454bddfd3fcd45039041ea161dbf826817822b",
"https://deno.land/std@0.150.0/log/handlers.ts": "b88c24df61eaeee8581dbef3622f21aebfd061cd2fda49affc1711c0e54d57da",
"https://deno.land/std@0.150.0/log/levels.ts": "82c965b90f763b5313e7595d4ba78d5095a13646d18430ebaf547526131604d1",
"https://deno.land/std@0.150.0/log/logger.ts": "4d25581bc02dfbe3ad7e8bb480e1f221793a85be5e056185a0cea134f7a7fdf4",
"https://deno.land/std@0.150.0/log/mod.ts": "65d2702785714b8d41061426b5c279f11b3dcbc716f3eb5384372a430af63961",
"https://deno.land/std@0.150.0/testing/_diff.ts": "029a00560b0d534bc0046f1bce4bd36b3b41ada3f2a3178c85686eb2ff5f1413",
"https://deno.land/std@0.150.0/testing/_format.ts": "0d8dc79eab15b67cdc532826213bbe05bccfd276ca473a50a3fc7bbfb7260642",
"https://deno.land/std@0.150.0/testing/_test_suite.ts": "ad453767aeb8c300878a6b7920e20370f4ce92a7b6c8e8a5d1ac2b7c14a09acb",
"https://deno.land/std@0.150.0/testing/asserts.ts": "0ee58a557ac764e762c62bb21f00e7d897e3919e71be38b2d574fb441d721005",
"https://deno.land/std@0.150.0/testing/bdd.ts": "182bb823e09bd75b76063ecf50722870101b7cfadf97a09fa29127279dc21128",
"https://deno.land/std@0.158.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
"https://deno.land/std@0.158.0/async/deferred.ts": "c01de44b9192359cebd3fe93273fcebf9e95110bf3360023917da9a2d1489fae",
"https://deno.land/std@0.158.0/async/delay.ts": "0419dfc993752849692d1f9647edf13407c7facc3509b099381be99ffbc9d699",
"https://deno.land/std@0.158.0/bytes/bytes_list.ts": "aba5e2369e77d426b10af1de0dcc4531acecec27f9b9056f4f7bfbf8ac147ab4",
"https://deno.land/std@0.158.0/bytes/equals.ts": "3c3558c3ae85526f84510aa2b48ab2ad7bdd899e2e0f5b7a8ffc85acb3a6043a",
"https://deno.land/std@0.158.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf",
"https://deno.land/std@0.158.0/io/buffer.ts": "fae02290f52301c4e0188670e730cd902f9307fb732d79c4aa14ebdc82497289",
"https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975",
"https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834",
"https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293",
"https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7",
"https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74",
"https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd",
"https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff",
"https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46",
"https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b",
"https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c",
"https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491",
"https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68",
"https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3",
"https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7",
"https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29",
"https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a",
"https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a",
"https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8",
"https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693",
"https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31",
"https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5",
"https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8",
"https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb",
"https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917",
"https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47",
"https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68",
"https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3",
"https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73",
"https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19",
"https://deno.land/std@0.224.0/async/delay.ts": "f90dd685b97c2f142b8069082993e437b1602b8e2561134827eeb7c12b95c499",
"https://deno.land/std@0.224.0/cli/parse_args.ts": "5250832fb7c544d9111e8a41ad272c016f5a53f975ef84d5a9fe5fcb70566ece",
"https://deno.land/std@0.224.0/encoding/_util.ts": "beacef316c1255da9bc8e95afb1fa56ed69baef919c88dc06ae6cb7a6103d376",
"https://deno.land/std@0.224.0/encoding/base64.ts": "dd59695391584c8ffc5a296ba82bcdba6dd8a84d41a6a539fbee8e5075286eaf",
"https://deno.land/std@0.224.0/fmt/bytes.ts": "7b294a4b9cf0297efa55acb55d50610f3e116a0ac772d1df0ae00f0b833ccd4a",
"https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5",
"https://deno.land/std@0.224.0/http/etag.ts": "9ca56531be682f202e4239971931060b688ee5c362688e239eeaca39db9e72cb",
"https://deno.land/std@0.224.0/http/file_server.ts": "2a5392195b8e7713288f274d071711b705bb5b3220294d76cce495d456c61a93",
"https://deno.land/std@0.224.0/http/server.ts": "f9313804bf6467a1704f45f76cb6cd0a3396a3b31c316035e6a4c2035d1ea514",
"https://deno.land/std@0.224.0/http/status.ts": "ed61b4882af2514a81aefd3245e8df4c47b9a8e54929a903577643d2d1ebf514",
"https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6",
"https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2",
"https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e",
"https://deno.land/std@0.224.0/media_types/_db.ts": "19563a2491cd81b53b9c1c6ffd1a9145c355042d4a854c52f6e1424f73ff3923",
"https://deno.land/std@0.224.0/media_types/_util.ts": "e0b8da0c7d8ad2015cf27ac16ddf0809ac984b2f3ec79f7fa4206659d4f10deb",
"https://deno.land/std@0.224.0/media_types/content_type.ts": "ed3f2e1f243b418ad3f441edc95fd92efbadb0f9bde36219c7564c67f9639513",
"https://deno.land/std@0.224.0/media_types/format_media_type.ts": "ffef4718afa2489530cb94021bb865a466eb02037609f7e82899c017959d288a",
"https://deno.land/std@0.224.0/media_types/get_charset.ts": "277ebfceb205bd34e616fe6764ef03fb277b77f040706272bea8680806ae3f11",
"https://deno.land/std@0.224.0/media_types/parse_media_type.ts": "487f000a38c230ccbac25420a50f600862e06796d0eee19d19631b9e84ee9654",
"https://deno.land/std@0.224.0/media_types/type_by_extension.ts": "bf4e3f5d6b58b624d5daa01cbb8b1e86d9939940a77e7c26e796a075b60ec73b",
"https://deno.land/std@0.224.0/media_types/vendor/mime-db.v1.52.0.ts": "0218d2c7d900e8cd6fa4a866e0c387712af4af9a1bae55d6b2546c73d273a1e6",
"https://deno.land/std@0.224.0/path/_common/assert_path.ts": "dbdd757a465b690b2cc72fc5fb7698c51507dec6bfafce4ca500c46b76ff7bd8",
"https://deno.land/std@0.224.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c",
"https://deno.land/std@0.224.0/path/_common/normalize.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8",
"https://deno.land/std@0.224.0/path/_common/normalize_string.ts": "33edef773c2a8e242761f731adeb2bd6d683e9c69e4e3d0092985bede74f4ac3",
"https://deno.land/std@0.224.0/path/_common/relative.ts": "faa2753d9b32320ed4ada0733261e3357c186e5705678d9dd08b97527deae607",
"https://deno.land/std@0.224.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15",
"https://deno.land/std@0.224.0/path/constants.ts": "0c206169ca104938ede9da48ac952de288f23343304a1c3cb6ec7625e7325f36",
"https://deno.land/std@0.224.0/path/extname.ts": "593303db8ae8c865cbd9ceec6e55d4b9ac5410c1e276bfd3131916591b954441",
"https://deno.land/std@0.224.0/path/join.ts": "ae2ec5ca44c7e84a235fd532e4a0116bfb1f2368b394db1c4fb75e3c0f26a33a",
"https://deno.land/std@0.224.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d",
"https://deno.land/std@0.224.0/path/posix/extname.ts": "e398c1d9d1908d3756a7ed94199fcd169e79466dd88feffd2f47ce0abf9d61d2",
"https://deno.land/std@0.224.0/path/posix/join.ts": "7fc2cb3716aa1b863e990baf30b101d768db479e70b7313b4866a088db016f63",
"https://deno.land/std@0.224.0/path/posix/normalize.ts": "baeb49816a8299f90a0237d214cef46f00ba3e95c0d2ceb74205a6a584b58a91",
"https://deno.land/std@0.224.0/path/posix/relative.ts": "3907d6eda41f0ff723d336125a1ad4349112cd4d48f693859980314d5b9da31c",
"https://deno.land/std@0.224.0/path/posix/resolve.ts": "08b699cfeee10cb6857ccab38fa4b2ec703b0ea33e8e69964f29d02a2d5257cf",
"https://deno.land/std@0.224.0/path/relative.ts": "ab739d727180ed8727e34ed71d976912461d98e2b76de3d3de834c1066667add",
"https://deno.land/std@0.224.0/path/resolve.ts": "a6f977bdb4272e79d8d0ed4333e3d71367cc3926acf15ac271f1d059c8494d8d",
"https://deno.land/std@0.224.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808",
"https://deno.land/std@0.224.0/path/windows/extname.ts": "165a61b00d781257fda1e9606a48c78b06815385e7d703232548dbfc95346bef",
"https://deno.land/std@0.224.0/path/windows/join.ts": "8d03530ab89195185103b7da9dfc6327af13eabdcd44c7c63e42e27808f50ecf",
"https://deno.land/std@0.224.0/path/windows/normalize.ts": "78126170ab917f0ca355a9af9e65ad6bfa5be14d574c5fb09bb1920f52577780",
"https://deno.land/std@0.224.0/path/windows/relative.ts": "3e1abc7977ee6cc0db2730d1f9cb38be87b0ce4806759d271a70e4997fc638d7",
"https://deno.land/std@0.224.0/path/windows/resolve.ts": "8dae1dadfed9d46ff46cc337c9525c0c7d959fb400a6308f34595c45bdca1972",
"https://deno.land/std@0.224.0/streams/byte_slice_stream.ts": "5bbdcadb118390affa9b3d0a0f73ef8e83754f59bb89df349add669dd9369713",
"https://deno.land/std@0.224.0/version.ts": "f6a28c9704d82d1c095988777e30e6172eb674a6570974a0d27a653be769bbbe",
"https://deno.land/x/socket_io@0.2.0/deps.ts": "136b3fc7c55f2a06a367965da3395f1bf7de66d36a0d91c5f795084fa8c67ab3",
"https://deno.land/x/socket_io@0.2.0/mod.ts": "61277a4145c378b602e8146ed4302117f130cf3b018c0a07bcb917e1b8c9fcf4",
"https://deno.land/x/socket_io@0.2.0/packages/engine.io-parser/base64-arraybuffer.ts": "57ccea6679609df5416159fcc8a47936ad28ad6fe32235ef78d9223a3a823407",
"https://deno.land/x/socket_io@0.2.0/packages/engine.io-parser/mod.ts": "27d35094e2159ba49f6e74f11ed83b6208a6adb5a2d5ab3cbbdcdc9dc0e36ae7",
"https://deno.land/x/socket_io@0.2.0/packages/engine.io/lib/cors.ts": "e39b530dc3526ef85f288766ce592fa5cce2ec38b3fa19922041a7885b79b67c",
"https://deno.land/x/socket_io@0.2.0/packages/engine.io/lib/server.ts": "1321852222ccf6b656787881fe0112c2a62930beaf1a56b6f5b327511323176f",
"https://deno.land/x/socket_io@0.2.0/packages/engine.io/lib/socket.ts": "b4ae4e2fad305c6178785a1a2ae220e38dfb31dc0ae43759c3d3a4f96ca48c9b",
"https://deno.land/x/socket_io@0.2.0/packages/engine.io/lib/transport.ts": "8d09ae6bde2f71942cfbae96265aa693387e054776cf2ef5a3b4f8aafa9a427f",
"https://deno.land/x/socket_io@0.2.0/packages/engine.io/lib/transports/polling.ts": "3d3cf369eb430360b57eaf18c74fb7783a1641ed8613c460cdfa8f663ca66be4",
"https://deno.land/x/socket_io@0.2.0/packages/engine.io/lib/transports/websocket.ts": "fd818e91e10c55b587a221669f90cc79df42574f781e50ef73bf3539fd9bcfee",
"https://deno.land/x/socket_io@0.2.0/packages/engine.io/lib/util.ts": "9f396a141422c8a2e2ef4cbb31c8b7ec96665d8f1ca397888eaaa9ad28ca8c65",
"https://deno.land/x/socket_io@0.2.0/packages/engine.io/mod.ts": "3f7d85ebd3bee6e17838f4867927d808f35090a71e088fd4dd802e3255d44c4a",
"https://deno.land/x/socket_io@0.2.0/packages/event-emitter/mod.ts": "dcb2cb9c0b409060cf15a6306a8dbebea844aa3c58f782ed1d4bc3ccef7c2835",
"https://deno.land/x/socket_io@0.2.0/packages/msgpack/lib/decode.ts": "5906fa37474130b09fe308adb53c95e40d2484a015891be3249fb2f626c462bb",
"https://deno.land/x/socket_io@0.2.0/packages/msgpack/lib/encode.ts": "15dab78be56d539c03748c9d57086f7fd580eb8fbe2f8209c28750948c7d962e",
"https://deno.land/x/socket_io@0.2.0/packages/msgpack/mod.ts": "c7f4a9859af3e0b23794b400546d93475b19ba5110a02245112a0a994a31d309",
"https://deno.land/x/socket_io@0.2.0/packages/socket.io-parser/mod.ts": "44479cf563b0ad80efedd1059cd40114bc5db199b45e623c2764e80f4a264f8c",
"https://deno.land/x/socket_io@0.2.0/packages/socket.io-redis-adapter/mod.ts": "45d6b7f077f94fec385152bda7fda5ac3153c2ca3548cf4859891af673fa97cc",
"https://deno.land/x/socket_io@0.2.0/packages/socket.io/lib/adapter.ts": "8f606f3fe57712221a73a6b01aa8bf1be39c4530ec8ebb8d2905d5313d4da9c4",
"https://deno.land/x/socket_io@0.2.0/packages/socket.io/lib/broadcast-operator.ts": "d842eb933acc996a05ac701f6d83ffee49ee9c905c9adbdee70832776045bf63",
"https://deno.land/x/socket_io@0.2.0/packages/socket.io/lib/client.ts": "b78e965dc3ab35d2fb9ccb859f4e1ce43d7c830aae9448d4958fa8ef9627eb4d",
"https://deno.land/x/socket_io@0.2.0/packages/socket.io/lib/namespace.ts": "920f16545ec4220831b0aa2164e256915c7f4dec318d1527efeae1596b831fe3",
"https://deno.land/x/socket_io@0.2.0/packages/socket.io/lib/parent-namespace.ts": "2d7f8a70498d161856aec522ae2f98727d58c5a9c252ad51a6ab5830b4fa9e2e",
"https://deno.land/x/socket_io@0.2.0/packages/socket.io/lib/server.ts": "bd450bea5573bb6144a5eded1c03dda93cb3ed8c8c671a6de0261f97307d6c71",
"https://deno.land/x/socket_io@0.2.0/packages/socket.io/lib/socket.ts": "7b88e37eabd31ce21039321325f51c955d9123133506acf3af692bf9337f081b",
"https://deno.land/x/socket_io@0.2.0/packages/socket.io/mod.ts": "dfd465bdcf23161af0c4d79fb8fc8912418c46a20d15e8b314cec6d9fb508196",
"https://deno.land/x/socket_io@0.2.0/test_deps.ts": "1f9dfa07a1e806ccddc9fa5f7255338d9dff67c40d7e83795f4f0f7bd710bde9",
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/backoff.ts": "33e4a6e245f8743fbae0ce583993a671a3ac2ecee433a3e7f0bd77b5dd541d84",
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/connection.ts": "c31d2e0cb360bc641e7286f1d53cf58790fbcda025c06887f84a821f39d0fdff",
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/errors.ts": "bc8f7091cb9f36cdd31229660e0139350b02c26851e3ac69d592c066745feb27",
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/executor.ts": "03e5f43df4e0c9c62b0e1be778811d45b6a1966ddf406e21ed5a227af70b7183",
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/mod.ts": "20908f005f5c102525ce6aa9261648c95c5f61c6cf782b2cbb2fce88b1220f69",
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/pipeline.ts": "80cc26a881149264d51dd019f1044c4ec9012399eca9f516057dc81c9b439370",
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/protocol/_util.ts": "0525f7f444a96b92cd36423abdfe221f8d8de4a018dc5cb6750a428a5fc897c2",
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/protocol/command.ts": "b1efd3b62fe5d1230e6d96b5c65ba7de1592a1eda2cc927161e5997a15f404ac",
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/protocol/mod.ts": "f2601df31d8adc71785b5d19f6a7e43dfce94adbb6735c4dafc1fb129169d11a",
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/protocol/reply.ts": "beac2061b03190bada179aef1a5d92b47a5104d9835e8c7468a55c24812ae9e4",
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/pubsub.ts": "324b87dae0700e4cb350780ce3ae5bc02780f79f3de35e01366b894668b016c6",
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/redis.ts": "a5c2cf8c72e7c92c9c8c6911f98227062649f6cba966938428c5414200f3aa54",
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/stream.ts": "f116d73cfe04590ff9fa8a3c08be8ff85219d902ef2f6929b8c1e88d92a07810",
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/vendor/https/deno.land/std/async/deferred.ts": "7391210927917113e04247ef013d800d54831f550e9a0b439244675c56058c55",
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/vendor/https/deno.land/std/async/delay.ts": "c7e2604f7cb5ef00d595de8dd600604902d5be03a183b515b3d6d4bbb48e1700",
"https://deno.land/x/socket_io@0.2.0/vendor/deno.land/x/redis@v0.27.1/vendor/https/deno.land/std/io/buffer.ts": "8c5f84b7ecf71bc3e12aa299a9fae9e72e495db05281fcdd62006ecd3c5ed3f3"
},
"workspace": {
"dependencies": [
"jsr:@oak/oak@^17.1.3",
"jsr:@std/crypto@^1.0.3",
"jsr:@std/encoding@^1.0.5",
"jsr:@std/fs@^1.0.8",
"jsr:@std/log@~0.224.9",
"jsr:@std/path@^1.0.8",
"jsr:@std/uuid@^1.0.4",
"npm:lru-cache@11",
"npm:music-metadata@^7.14.0"
]
}
}

View File

@@ -0,0 +1,179 @@
import type { GamePhase, GameState, GameStatus, GuessResult, ID, TimelinePosition } from '../types.ts';
/**
* Game state domain model
* Encapsulates all game state and provides methods to manipulate it
*/
export class GameStateModel implements GameState {
status: GameStatus;
phase: GamePhase;
turnOrder: ID[];
currentGuesser: ID | null;
currentTrack: any;
timeline: Record<ID, TimelinePosition[]>;
tokens: Record<ID, number>;
ready: Record<ID, boolean>;
spectators: Record<ID, boolean>;
lastResult: GuessResult | null;
trackStartAt: number | null;
paused: boolean;
pausedPosSec: number;
goal: number;
playlist: string | null;
titleArtistAwardedThisRound: Record<ID, boolean>;
constructor(goal = 10) {
this.status = 'lobby' as GameStatus;
this.phase = 'guess' as GamePhase;
this.turnOrder = [];
this.currentGuesser = null;
this.currentTrack = null;
this.timeline = {};
this.tokens = {};
this.ready = {};
this.spectators = {};
this.lastResult = null;
this.trackStartAt = null;
this.paused = false;
this.pausedPosSec = 0;
this.goal = goal;
this.playlist = null;
this.titleArtistAwardedThisRound = {};
}
/**
* Initialize player in game state
*/
addPlayer(playerId: ID, isHost = false): void {
this.ready[playerId] = isHost; // Host is ready by default
this.timeline[playerId] = [];
this.tokens[playerId] = 0;
}
/**
* Remove player from game state
*/
removePlayer(playerId: ID): void {
delete this.ready[playerId];
delete this.timeline[playerId];
delete this.tokens[playerId];
delete this.spectators[playerId];
this.turnOrder = this.turnOrder.filter((id) => id !== playerId);
}
/**
* Set player ready status
*/
setReady(playerId: ID, ready: boolean): void {
this.ready[playerId] = ready;
}
/**
* Check if all players are ready
*/
areAllReady(): boolean {
const playerIds = Object.keys(this.ready).filter((id) => !this.spectators[id]);
return playerIds.length > 0 && playerIds.every((id) => this.ready[id]);
}
/**
* Start the game
*/
startGame(playerIds: ID[]): void {
this.status = 'playing' as GameStatus;
this.turnOrder = this.shuffleArray([...playerIds.filter((id) => !this.spectators[id])]);
this.currentGuesser = this.turnOrder[0] || null;
}
/**
* End the game
*/
endGame(): void {
this.status = 'ended' as GameStatus;
this.currentTrack = null;
this.currentGuesser = null;
}
/**
* Move to next player's turn
*/
nextTurn(): ID | null {
if (!this.currentGuesser || this.turnOrder.length === 0) {
return this.turnOrder[0] || null;
}
const currentIndex = this.turnOrder.indexOf(this.currentGuesser);
const nextIndex = (currentIndex + 1) % this.turnOrder.length;
this.currentGuesser = this.turnOrder[nextIndex];
return this.currentGuesser;
}
/**
* Award tokens to a player
*/
awardTokens(playerId: ID, amount: number): void {
this.tokens[playerId] = (this.tokens[playerId] || 0) + amount;
}
/**
* Add card to player's timeline
*/
addToTimeline(playerId: ID, year: number, position: number): void {
if (!this.timeline[playerId]) {
this.timeline[playerId] = [];
}
this.timeline[playerId].push({ year, position });
this.timeline[playerId].sort((a, b) => a.position - b.position);
}
/**
* Check if player has won
*/
hasPlayerWon(playerId: ID): boolean {
return (this.timeline[playerId]?.length || 0) >= this.goal;
}
/**
* Get winner (if any)
*/
getWinner(): ID | null {
for (const playerId of Object.keys(this.timeline)) {
if (this.hasPlayerWon(playerId)) {
return playerId;
}
}
return null;
}
/**
* Set spectator status
*/
setSpectator(playerId: ID, spectator: boolean): void {
this.spectators[playerId] = spectator;
if (spectator) {
// Remove from turn order if spectating
this.turnOrder = this.turnOrder.filter((id) => id !== playerId);
}
}
/**
* Reset round state (after track is complete)
* Note: currentTrack and trackStartAt are set separately by the caller
*/
resetRound(): void {
this.phase = 'guess' as GamePhase;
this.lastResult = null;
this.titleArtistAwardedThisRound = {};
}
/**
* Utility: Shuffle array
*/
private shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
}

View File

@@ -0,0 +1,79 @@
import type { ID, Player } from '../types.ts';
/**
* Player domain model
* Represents a player in the game with their connection state
*/
export class PlayerModel implements Player {
id: ID;
sessionId: ID;
name: string;
connected: boolean;
roomId: ID | null;
spectator?: boolean;
constructor(
id: ID,
sessionId: ID,
name?: string,
connected = true,
roomId: ID | null = null,
) {
this.id = id;
this.sessionId = sessionId;
this.name = name || `Player-${id.slice(0, 4)}`;
this.connected = connected;
this.roomId = roomId;
}
/**
* Update player's connection status
*/
setConnected(connected: boolean): void {
this.connected = connected;
}
/**
* Update player's name
*/
setName(name: string): void {
if (name && name.trim().length > 0) {
this.name = name.trim();
}
}
/**
* Join a room
*/
joinRoom(roomId: ID): void {
this.roomId = roomId;
}
/**
* Leave current room
*/
leaveRoom(): void {
this.roomId = null;
}
/**
* Toggle spectator mode
*/
setSpectator(spectator: boolean): void {
this.spectator = spectator;
}
/**
* Create a safe representation for serialization
*/
toJSON(): Player {
return {
id: this.id,
sessionId: this.sessionId,
name: this.name,
connected: this.connected,
roomId: this.roomId,
spectator: this.spectator,
};
}
}

View File

@@ -0,0 +1,143 @@
import type { ID, Player, Room, Track } from '../types.ts';
import { GameStateModel } from './GameState.ts';
/**
* Room domain model
* Represents a game room with players and game state
*/
export class RoomModel implements Room {
id: ID;
name: string;
hostId: ID;
players: Map<ID, Player>;
deck: Track[];
discard: Track[];
state: GameStateModel;
constructor(id: ID, name: string, host: Player, goal = 10) {
this.id = id;
this.name = name || `Room ${id}`;
this.hostId = host.id;
this.players = new Map([[host.id, host]]);
this.deck = [];
this.discard = [];
this.state = new GameStateModel(goal);
// Initialize host in game state (not ready by default)
this.state.addPlayer(host.id, false);
}
/**
* Add a player to the room
*/
addPlayer(player: Player): boolean {
if (this.players.has(player.id)) {
return false;
}
this.players.set(player.id, player);
this.state.addPlayer(player.id);
return true;
}
/**
* Remove a player from the room
*/
removePlayer(playerId: ID): boolean {
const removed = this.players.delete(playerId);
if (removed) {
this.state.removePlayer(playerId);
}
return removed;
}
/**
* Get player by ID
*/
getPlayer(playerId: ID): Player | undefined {
return this.players.get(playerId);
}
/**
* Check if player is in room
*/
hasPlayer(playerId: ID): boolean {
return this.players.has(playerId);
}
/**
* Check if player is host
*/
isHost(playerId: ID): boolean {
return this.hostId === playerId;
}
/**
* Transfer host to another player
*/
transferHost(newHostId: ID): boolean {
if (!this.players.has(newHostId)) {
return false;
}
this.hostId = newHostId;
return true;
}
/**
* Get all connected players
*/
getConnectedPlayers(): Player[] {
return Array.from(this.players.values()).filter((p) => p.connected);
}
/**
* Get all players (including disconnected)
*/
getAllPlayers(): Player[] {
return Array.from(this.players.values());
}
/**
* Set deck of tracks
*/
setDeck(tracks: Track[]): void {
this.deck = [...tracks];
}
/**
* Draw next track from deck
*/
drawTrack(): Track | null {
const track = this.deck.shift();
if (track) {
this.discard.push(track);
return track;
}
return null;
}
/**
* Check if deck is empty
*/
isDeckEmpty(): boolean {
return this.deck.length === 0;
}
/**
* Get room summary for serialization
*/
toSummary() {
return {
id: this.id,
name: this.name,
hostId: this.hostId,
players: this.getAllPlayers().map((p) => ({
id: p.id,
name: p.name,
connected: p.connected,
ready: this.state.ready[p.id] || false,
spectator: this.state.spectators[p.id] || false,
})),
state: this.state,
};
}
}

View File

@@ -0,0 +1,6 @@
/**
* Domain model exports
*/
export { PlayerModel } from './Player.ts';
export { GameStateModel } from './GameState.ts';
export { RoomModel } from './Room.ts';

View File

@@ -0,0 +1,150 @@
/**
* Core domain types for the Hitstar game
*/
/**
* Unique identifier type
*/
export type ID = string;
/**
* Game status enum
*/
export enum GameStatus {
LOBBY = 'lobby',
PLAYING = 'playing',
ENDED = 'ended',
}
/**
* Game phase during active play
*/
export enum GamePhase {
GUESS = 'guess',
REVEAL = 'reveal',
}
/**
* Track metadata
*/
export interface Track {
id: string;
file: string;
title: string;
artist: string;
year: number | null;
url?: string; // Token-based streaming URL
}
/**
* Player in a room
*/
export interface Player {
id: ID;
sessionId: ID;
name: string;
connected: boolean;
roomId: ID | null;
spectator?: boolean;
}
/**
* Player timeline position (for card placement)
*/
export interface TimelinePosition {
trackId: string;
year: number | null;
title: string;
artist: string;
}
/**
* Game state
*/
export interface GameState {
status: GameStatus;
phase: GamePhase;
turnOrder: ID[]; // Player IDs in turn order
currentGuesser: ID | null;
currentTrack: Track | null;
timeline: Record<ID, TimelinePosition[]>; // Player ID -> their timeline cards
tokens: Record<ID, number>; // Player ID -> token/coin count
ready: Record<ID, boolean>; // Player ID -> ready status in lobby
spectators: Record<ID, boolean>; // Player ID -> spectator flag
lastResult: GuessResult | null;
trackStartAt: number | null; // Timestamp when track started playing
paused: boolean;
pausedPosSec: number;
goal: number; // Win condition (e.g., 10 cards)
playlist: string | null; // Selected playlist ID
titleArtistAwardedThisRound: Record<ID, boolean>; // Track if title+artist token awarded this round
}
/**
* Room containing players and game state
*/
export interface Room {
id: ID;
name: string;
hostId: ID;
players: Map<ID, Player>;
deck: Track[]; // Unplayed tracks
discard: Track[]; // Played tracks
state: GameState;
}
/**
* Result of a guess attempt
*/
export interface GuessResult {
playerId: ID;
playerName?: string;
guess?: string | null;
correct: boolean;
type: 'title' | 'artist' | 'year' | 'placement';
score?: number; // Similarity score for partial matches
answer?: string; // The correct answer
tokensUsed?: boolean; // Whether tokens were used for guaranteed placement
}
/**
* Playlist metadata
*/
export interface Playlist {
id: string;
name: string;
trackCount: number;
}
/**
* Years index structure (loaded from years.json)
*/
export interface YearsIndex {
byFile: Record<string, YearMetadata>;
}
/**
* Metadata for a single track in years.json
*/
export interface YearMetadata {
year: number | null;
title?: string;
artist?: string;
}
/**
* Audio token metadata
*/
export interface AudioToken {
path: string;
mime: string;
size: number;
}
/**
* Cover art data
*/
export interface CoverArt {
mime: string;
buf: Uint8Array;
}

View File

@@ -0,0 +1,218 @@
import { join } from '@std/path';
import type { Context } from '@oak/oak';
import { FileSystemService } from './FileSystemService.ts';
import { TokenStoreService } from './TokenStoreService.ts';
import { getMimeType } from './MimeTypeService.ts';
import { PREFERRED_EXTENSION } from '../shared/constants.ts';
import { NotFoundError, ValidationError } from '../shared/errors.ts';
import { logger } from '../shared/logger.ts';
import type { AudioToken } from '../domain/types.ts';
/**
* Audio streaming service for serving audio files
*/
export class AudioStreamingService {
constructor(
private readonly fileSystem: FileSystemService,
private readonly tokenStore: TokenStoreService,
) {}
/**
* Create a streaming token for an audio file
*/
async createAudioToken(fileName: string, ttlMs?: number): Promise<string> {
const resolved = this.fileSystem.resolveSafePath(fileName);
if (!await this.fileSystem.fileExists(resolved)) {
throw new NotFoundError('Audio file not found');
}
// Prefer .opus sibling for streaming (better compression)
let finalPath = resolved;
const ext = resolved.slice(resolved.lastIndexOf('.')).toLowerCase();
if (ext !== PREFERRED_EXTENSION) {
const opusCandidate = resolved.slice(0, -ext.length) + PREFERRED_EXTENSION;
if (await this.fileSystem.fileExists(opusCandidate)) {
finalPath = opusCandidate;
}
}
// Get file info
const stat = await this.fileSystem.statFile(finalPath);
const mime = getMimeType(finalPath, 'audio/mpeg');
const tokenData: AudioToken = {
path: finalPath,
mime,
size: stat.size,
};
return this.tokenStore.putToken(tokenData, ttlMs);
}
/**
* Handle HEAD request for audio streaming
*/
async handleHeadRequest(ctx: Context, token: string): Promise<void> {
const tokenData = this.tokenStore.getToken(token);
if (!tokenData) {
throw new NotFoundError('Token not found or expired');
}
this.setCommonHeaders(ctx);
ctx.response.headers.set('Content-Type', tokenData.mime);
ctx.response.headers.set('Content-Length', String(tokenData.size));
ctx.response.status = 200;
}
/**
* Handle GET request for audio streaming with range support
*/
async handleStreamRequest(ctx: Context, token: string): Promise<void> {
const tokenData = this.tokenStore.getToken(token);
if (!tokenData) {
throw new NotFoundError('Token not found or expired');
}
const { path: filePath, mime, size } = tokenData;
const rangeHeader = ctx.request.headers.get('range');
this.setCommonHeaders(ctx);
if (rangeHeader) {
await this.streamRange(ctx, filePath, size, mime, rangeHeader);
} else {
await this.streamFull(ctx, filePath, size, mime);
}
}
/**
* Stream full file
*/
private async streamFull(
ctx: Context,
filePath: string,
size: number,
mime: string,
): Promise<void> {
ctx.response.status = 200;
ctx.response.headers.set('Content-Type', mime);
ctx.response.headers.set('Content-Length', String(size));
const file = await Deno.open(filePath, { read: true });
ctx.response.body = file.readable;
}
/**
* Stream file range (for seeking/partial content)
*/
private async streamRange(
ctx: Context,
filePath: string,
fileSize: number,
mime: string,
rangeHeader: string,
): Promise<void> {
const match = /bytes=(\d+)-(\d+)?/.exec(rangeHeader);
if (!match) {
throw new ValidationError('Invalid range header');
}
let start = parseInt(match[1], 10);
let end = match[2] ? parseInt(match[2], 10) : fileSize - 1;
// Validate and clamp range
if (isNaN(start)) start = 0;
if (isNaN(end)) end = fileSize - 1;
start = Math.min(Math.max(0, start), Math.max(0, fileSize - 1));
end = Math.min(Math.max(start, end), Math.max(0, fileSize - 1));
if (start > end || start >= fileSize) {
ctx.response.status = 416; // Range Not Satisfiable
ctx.response.headers.set('Content-Range', `bytes */${fileSize}`);
return;
}
const chunkSize = end - start + 1;
ctx.response.status = 206; // Partial Content
ctx.response.headers.set('Content-Type', mime);
ctx.response.headers.set('Content-Length', String(chunkSize));
ctx.response.headers.set('Content-Range', `bytes ${start}-${end}/${fileSize}`);
// Open file and seek to start position
const file = await Deno.open(filePath, { read: true });
// Create a readable stream for the range
const reader = file.readable.getReader();
const stream = new ReadableStream({
async start(controller) {
try {
let bytesRead = 0;
let totalSkipped = 0;
while (totalSkipped < start) {
const { value, done } = await reader.read();
if (done) break;
const remaining = start - totalSkipped;
if (value.length <= remaining) {
totalSkipped += value.length;
} else {
// Partial skip - send remainder
const chunk = value.slice(remaining);
controller.enqueue(chunk);
bytesRead += chunk.length;
totalSkipped = start;
}
}
// Read the requested range
while (bytesRead < chunkSize) {
const { value, done } = await reader.read();
if (done) break;
const remaining = chunkSize - bytesRead;
if (value.length <= remaining) {
controller.enqueue(value);
bytesRead += value.length;
} else {
controller.enqueue(value.slice(0, remaining));
bytesRead += remaining;
}
}
controller.close();
} catch (error) {
controller.error(error);
} finally {
reader.releaseLock();
file.close();
}
},
});
ctx.response.body = stream;
}
/**
* Set common caching headers
*/
private setCommonHeaders(ctx: Context): void {
// Allow caching of audio content to prevent redundant range requests
// The token system already provides security
ctx.response.headers.set('Cache-Control', 'public, max-age=3600');
ctx.response.headers.set('Accept-Ranges', 'bytes');
// iOS Safari specific headers to improve streaming
ctx.response.headers.set('Access-Control-Allow-Origin', '*');
ctx.response.headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
ctx.response.headers.set('Access-Control-Allow-Headers', 'Range');
ctx.response.headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges');
}
}

View File

@@ -0,0 +1,70 @@
import { LRUCache } from 'lru-cache';
import { parseFile } from 'music-metadata';
import type { CoverArt } from '../domain/types.ts';
import { COVER_CACHE_MAX_ITEMS, COVER_CACHE_MAX_BYTES } from '../shared/constants.ts';
import { logger } from '../shared/logger.ts';
/**
* Cover art caching service
*/
export class CoverArtService {
private readonly coverCache: LRUCache<string, CoverArt>;
constructor() {
this.coverCache = new LRUCache<string, CoverArt>({
max: COVER_CACHE_MAX_ITEMS,
maxSize: COVER_CACHE_MAX_BYTES,
sizeCalculation: (value) => value.buf.length,
ttl: 5 * 60 * 1000, // 5 minutes
ttlAutopurge: true,
});
}
/**
* Get cover art from audio file
*/
async getCoverArt(filePath: string): Promise<CoverArt | null> {
// Check cache first
const cached = this.coverCache.get(filePath);
if (cached) {
return cached;
}
try {
// Parse metadata from audio file
const metadata = await parseFile(filePath, { duration: false });
const picture = metadata.common?.picture?.[0];
if (!picture?.data) {
return null;
}
const coverArt: CoverArt = {
mime: picture.format || 'image/jpeg',
buf: new Uint8Array(picture.data),
};
// Cache the result
this.coverCache.set(filePath, coverArt);
return coverArt;
} catch (error) {
logger.error(`Failed to extract cover art from ${filePath}: ${error}`);
return null;
}
}
/**
* Clear cache
*/
clearCache(): void {
this.coverCache.clear();
}
/**
* Get cache size
*/
getCacheSize(): number {
return this.coverCache.size;
}
}

View File

@@ -0,0 +1,186 @@
import { join, resolve, normalize } from '@std/path';
import { exists } from '@std/fs';
import type { AppConfig } from '../shared/config.ts';
import { NotFoundError, ValidationError } from '../shared/errors.ts';
/**
* File system service for managing file paths and operations
*/
export class FileSystemService {
private readonly dataDir: string;
constructor(private readonly config: AppConfig) {
this.dataDir = resolve(config.dataDir);
}
/**
* Resolve a safe path within the data directory (prevent path traversal)
*/
resolveSafePath(name: string): string {
if (!name || typeof name !== 'string') {
throw new ValidationError('Invalid path name');
}
const joined = join(this.dataDir, name);
const resolved = resolve(joined);
// Normalize both paths for consistent comparison
const normalizedDataDir = normalize(this.dataDir);
const normalizedResolved = normalize(resolved);
// Ensure resolved path is within data directory
// Check if paths are equal or if resolved starts with dataDir
if (normalizedResolved === normalizedDataDir) {
return resolved;
}
// Check if the resolved path is a subdirectory of dataDir
// Add separator to prevent partial directory name matches (e.g., /data vs /data2)
const dataDirWithSep = normalizedDataDir + (normalizedDataDir.endsWith('\\') || normalizedDataDir.endsWith('/') ? '' : '\\');
const dataDirWithSepAlt = normalizedDataDir + (normalizedDataDir.endsWith('\\') || normalizedDataDir.endsWith('/') ? '' : '/');
if (normalizedResolved.startsWith(dataDirWithSep) || normalizedResolved.startsWith(dataDirWithSepAlt)) {
return resolved;
}
throw new ValidationError('Path traversal detected');
}
/**
* Check if file exists
*/
async fileExists(path: string): Promise<boolean> {
try {
return await exists(path);
} catch {
return false;
}
}
/**
* Get file stat information
*/
async statFile(path: string): Promise<Deno.FileInfo> {
try {
return await Deno.stat(path);
} catch (error) {
throw new NotFoundError(`File not found: ${path}`);
}
}
/**
* Read file as text
*/
async readTextFile(path: string): Promise<string> {
try {
return await Deno.readTextFile(path);
} catch (error) {
throw new NotFoundError(`Cannot read file: ${path}`);
}
}
/**
* Read file as JSON
*/
async readJsonFile<T>(path: string, fallback?: T): Promise<T> {
try {
const text = await this.readTextFile(path);
return JSON.parse(text);
} catch (error) {
if (fallback !== undefined) {
return fallback;
}
throw new NotFoundError(`Cannot read JSON file: ${path}`);
}
}
/**
* Write text to file
*/
async writeTextFile(path: string, content: string): Promise<void> {
try {
await Deno.writeTextFile(path, content);
} catch (error) {
throw new Error(`Cannot write file: ${path}`);
}
}
/**
* Write JSON to file
*/
async writeJsonFile(path: string, data: unknown): Promise<void> {
const content = JSON.stringify(data, null, 2);
await this.writeTextFile(path, content);
}
/**
* List files in directory
*/
async listFiles(dirPath: string, pattern?: RegExp): Promise<string[]> {
const files: string[] = [];
try {
for await (const entry of Deno.readDir(dirPath)) {
if (entry.isFile) {
if (!pattern || pattern.test(entry.name)) {
files.push(entry.name);
}
}
}
} catch (error) {
throw new NotFoundError(`Cannot list directory: ${dirPath}`);
}
return files;
}
/**
* List subdirectories
*/
async listDirectories(dirPath: string): Promise<string[]> {
const dirs: string[] = [];
try {
for await (const entry of Deno.readDir(dirPath)) {
if (entry.isDirectory) {
dirs.push(entry.name);
}
}
} catch (error) {
throw new NotFoundError(`Cannot list directory: ${dirPath}`);
}
return dirs;
}
/**
* Get playlist directory path
*/
getPlaylistDir(playlistId: string = 'default'): string {
return playlistId === 'default'
? this.dataDir
: join(this.dataDir, playlistId);
}
/**
* Get years.json path for playlist
*/
getYearsPath(playlistId: string = 'default'): string {
const dir = this.getPlaylistDir(playlistId);
return join(dir, 'years.json');
}
/**
* Get data directory
*/
getDataDir(): string {
return this.dataDir;
}
/**
* Get public directory
*/
getPublicDir(): string {
return resolve(this.config.publicDir);
}
}

View File

@@ -0,0 +1,157 @@
import { parseFile } from 'music-metadata';
import { join } from '@std/path';
import type { Track, YearMetadata, YearsIndex } from '../domain/types.ts';
import { FileSystemService } from './FileSystemService.ts';
import { AUDIO_EXTENSIONS, PREFERRED_EXTENSION, BATCH_SIZE } from '../shared/constants.ts';
import { logger } from '../shared/logger.ts';
/**
* Metadata service for parsing audio file metadata
*/
export class MetadataService {
constructor(private readonly fileSystem: FileSystemService) {}
/**
* Load years index from years.json
*/
async loadYearsIndex(playlistId: string = 'default'): Promise<Record<string, YearMetadata>> {
const yearsPath = this.fileSystem.getYearsPath(playlistId);
try {
const yearsData = await this.fileSystem.readJsonFile<YearsIndex>(yearsPath);
if (yearsData && yearsData.byFile && typeof yearsData.byFile === 'object') {
return yearsData.byFile;
}
} catch (error) {
logger.debug(`No years.json found for playlist '${playlistId}' at ${yearsPath}`);
}
return {};
}
/**
* Parse audio file metadata
*/
async parseAudioMetadata(filePath: string): Promise<{
title?: string;
artist?: string;
}> {
try {
const metadata = await parseFile(filePath, { duration: false });
return {
title: metadata.common.title,
artist: metadata.common.artist,
};
} catch (error) {
logger.error(`Failed to parse metadata from ${filePath}: ${error}`);
return {};
}
}
/**
* Load tracks from a playlist directory
*/
async loadTracksFromPlaylist(playlistId: string = 'default'): Promise<Track[]> {
const yearsIndex = await this.loadYearsIndex(playlistId);
const playlistDir = this.fileSystem.getPlaylistDir(playlistId);
// Check if directory exists
if (!await this.fileSystem.fileExists(playlistDir)) {
logger.error(`Playlist directory not found: ${playlistDir}`);
return [];
}
// Get audio files
const audioPattern = new RegExp(`(${AUDIO_EXTENSIONS.join('|').replace(/\./g, '\\.')})$`, 'i');
const files = await this.fileSystem.listFiles(playlistDir, audioPattern);
if (files.length === 0) {
logger.warn(`No audio files found in playlist: ${playlistId}`);
return [];
}
// Deduplicate files (prefer .opus)
const uniqueFiles = this.deduplicateFiles(files);
// Process in batches to avoid "too many open files" error
const tracks: Track[] = [];
for (let i = 0; i < uniqueFiles.length; i += BATCH_SIZE) {
const batch = uniqueFiles.slice(i, i + BATCH_SIZE);
const batchTracks = await Promise.all(
batch.map((fileName) => this.createTrackFromFile(fileName, playlistId, playlistDir, yearsIndex))
);
tracks.push(...batchTracks);
}
return tracks;
}
/**
* Create track object from file
*/
private async createTrackFromFile(
fileName: string,
playlistId: string,
playlistDir: string,
yearsIndex: Record<string, YearMetadata>
): Promise<Track> {
const filePath = join(playlistDir, fileName);
// For file references, include playlist subdirectory if not default
const relativeFile = playlistId === 'default' ? fileName : join(playlistId, fileName);
// Get metadata from years.json first (priority)
const jsonMeta = yearsIndex[fileName];
let year = jsonMeta?.year ?? null;
let title = jsonMeta?.title ?? this.getFileNameWithoutExt(fileName);
let artist = jsonMeta?.artist ?? '';
// Parse audio file metadata if JSON doesn't have title or artist
if (!jsonMeta || !jsonMeta.title || !jsonMeta.artist) {
const audioMeta = await this.parseAudioMetadata(filePath);
title = jsonMeta?.title || audioMeta.title || title;
artist = jsonMeta?.artist || audioMeta.artist || artist;
}
return {
id: relativeFile,
file: relativeFile,
title,
artist,
year,
};
}
/**
* Deduplicate files, preferring .opus versions
*/
private deduplicateFiles(files: string[]): string[] {
const fileMap = new Map<string, string>();
for (const file of files) {
const baseName = this.getFileNameWithoutExt(file);
const ext = file.slice(file.lastIndexOf('.')).toLowerCase();
const existing = fileMap.get(baseName);
if (!existing) {
fileMap.set(baseName, file);
} else if (ext === PREFERRED_EXTENSION) {
// Prefer .opus version
fileMap.set(baseName, file);
}
}
return Array.from(fileMap.values());
}
/**
* Get filename without extension
*/
private getFileNameWithoutExt(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
return lastDot > 0 ? fileName.slice(0, lastDot) : fileName;
}
}

View File

@@ -0,0 +1,40 @@
/**
* MIME type utility for determining content types
*/
const MIME_TYPES: Record<string, string> = {
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.m4a': 'audio/mp4',
'.ogg': 'audio/ogg',
'.opus': 'audio/ogg', // Opus in Ogg container
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
};
/**
* Get MIME type from file extension
*/
export function getMimeType(filePath: string, fallback = 'application/octet-stream'): string {
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
return MIME_TYPES[ext] || fallback;
}
/**
* Check if file is an audio file
*/
export function isAudioFile(filePath: string): boolean {
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
return ['.mp3', '.wav', '.m4a', '.ogg', '.opus'].includes(ext);
}
/**
* Check if file is an image file
*/
export function isImageFile(filePath: string): boolean {
const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase();
return ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
}

View File

@@ -0,0 +1,72 @@
import { encodeHex } from '@std/encoding/hex';
import { LRUCache } from 'lru-cache';
import type { AudioToken } from '../domain/types.ts';
import { TOKEN_CACHE_MAX_ITEMS, TOKEN_TTL_MS } from '../shared/constants.ts';
/**
* Token store service for managing short-lived audio streaming tokens
*/
export class TokenStoreService {
private readonly tokenCache: LRUCache<string, AudioToken>;
constructor(ttlMs: number = TOKEN_TTL_MS) {
this.tokenCache = new LRUCache<string, AudioToken>({
max: TOKEN_CACHE_MAX_ITEMS,
ttl: ttlMs,
ttlAutopurge: true,
allowStale: false,
updateAgeOnGet: false,
updateAgeOnHas: false,
});
}
/**
* Generate a random token
*/
private generateToken(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return encodeHex(bytes);
}
/**
* Create and store a new token
*/
putToken(value: AudioToken, ttlMs?: number): string {
const token = this.generateToken();
const options = ttlMs ? { ttl: Math.max(1000, ttlMs) } : undefined;
this.tokenCache.set(token, value, options);
return token;
}
/**
* Retrieve token data
*/
getToken(token: string): AudioToken | undefined {
if (!token) return undefined;
return this.tokenCache.get(token);
}
/**
* Remove a token
*/
deleteToken(token: string): boolean {
return this.tokenCache.delete(token);
}
/**
* Clear all tokens
*/
clearAll(): void {
this.tokenCache.clear();
}
/**
* Get cache size
*/
getSize(): number {
return this.tokenCache.size;
}
}

View File

@@ -0,0 +1,9 @@
/**
* Infrastructure layer exports
*/
export { FileSystemService } from './FileSystemService.ts';
export { TokenStoreService } from './TokenStoreService.ts';
export { CoverArtService } from './CoverArtService.ts';
export { MetadataService } from './MetadataService.ts';
export { AudioStreamingService } from './AudioStreamingService.ts';
export { getMimeType, isAudioFile, isImageFile } from './MimeTypeService.ts';

210
src/server-deno/main.ts Normal file
View File

@@ -0,0 +1,210 @@
#!/usr/bin/env -S deno run --allow-net --allow-read --allow-env --allow-write
import { serve } from 'https://deno.land/std@0.224.0/http/server.ts';
import { serveDir } from 'https://deno.land/std@0.224.0/http/file_server.ts';
import { loadConfig } from './shared/config.ts';
import { initLogger, logger } from './shared/logger.ts';
// Infrastructure
import {
AudioStreamingService,
CoverArtService,
FileSystemService,
MetadataService,
TokenStoreService,
} from './infrastructure/mod.ts';
// Application
import {
AnswerCheckService,
GameService,
RoomService,
TrackService,
} from './application/mod.ts';
// Presentation
import { WebSocketServer } from './presentation/WebSocketServer.ts';
/**
* Main application entry point
*/
async function main() {
// Load configuration
const config = loadConfig();
// Initialize logger
initLogger(config.logLevel);
logger.info('Starting Hitstar Server (Deno 2 + TypeScript)');
logger.info(`Port: ${config.port}`);
logger.info(`Data directory: ${config.dataDir}`);
logger.info(`Public directory: ${config.publicDir}`);
try {
// Initialize infrastructure services
const fileSystem = new FileSystemService(config);
const tokenStore = new TokenStoreService(config.tokenTtlMs);
const coverArt = new CoverArtService();
const metadata = new MetadataService(fileSystem);
const audioStreaming = new AudioStreamingService(fileSystem, tokenStore);
logger.info('Infrastructure services initialized');
// Initialize application services
const answerCheck = new AnswerCheckService();
const trackService = new TrackService(fileSystem, metadata);
const roomService = new RoomService();
const gameService = new GameService(trackService, audioStreaming, answerCheck);
logger.info('Application services initialized');
// Preload playlists in the background (non-blocking)
// This ensures fast game starts by caching track metadata
trackService.preloadAllPlaylists();
// Initialize WebSocket server
const wsServer = new WebSocketServer(roomService, gameService);
wsServer.initialize(config.corsOrigin);
// Create combined handler
const handler = async (request: Request, info: Deno.ServeHandlerInfo): Promise<Response> => {
const url = new URL(request.url);
// Socket.IO requests
if (url.pathname.startsWith('/socket.io/')) {
// Convert ServeHandlerInfo to ConnInfo format for Socket.IO compatibility
// Socket.IO 0.2.0 expects old ConnInfo format with localAddr and remoteAddr
const connInfo = {
localAddr: {
transport: 'tcp' as const,
hostname: config.host,
port: config.port
},
remoteAddr: info.remoteAddr,
};
return await wsServer.getHandler()(request, connInfo);
}
// API endpoints
if (url.pathname.startsWith('/api/')) {
if (url.pathname === '/api/playlists') {
const playlists = await trackService.getAvailablePlaylists();
return new Response(JSON.stringify({ ok: true, playlists }), {
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
});
}
if (url.pathname === '/api/tracks') {
const playlistId = url.searchParams.get('playlist') || 'default';
const tracks = await trackService.loadPlaylistTracks(playlistId);
return new Response(JSON.stringify({ ok: true, tracks, playlist: playlistId }), {
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
});
}
if (url.pathname === '/api/reload-years') {
const playlistId = url.searchParams.get('playlist') || 'default';
const result = await trackService.reloadYearsIndex(playlistId);
return new Response(JSON.stringify({ ok: true, count: result.count, playlist: playlistId }), {
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
});
}
}
// Audio streaming
if (url.pathname.startsWith('/audio/t/')) {
const token = url.pathname.split('/audio/t/')[1];
try {
// Create a minimal context-like object
const ctx: any = {
request: { headers: new Headers(request.headers), url },
response: { headers: new Headers(), status: 200 },
};
if (request.method === 'HEAD') {
await audioStreaming.handleHeadRequest(ctx, token);
return new Response(null, {
status: ctx.response.status,
headers: ctx.response.headers,
});
} else {
await audioStreaming.handleStreamRequest(ctx, token);
return new Response(ctx.response.body, {
status: ctx.response.status,
headers: ctx.response.headers,
});
}
} catch (error) {
return new Response('Not found', { status: 404 });
}
}
// Cover art
if (url.pathname.startsWith('/cover/')) {
const encodedFileName = url.pathname.split('/cover/')[1];
const fileName = decodeURIComponent(encodedFileName);
try {
const cover = await coverArt.getCoverArt(fileSystem.resolveSafePath(fileName));
if (cover) {
return new Response(cover.buf, {
headers: {
'Content-Type': cover.mime,
'Cache-Control': 'public, max-age=3600',
},
});
}
} catch { }
return new Response('Not found', { status: 404 });
}
// Static files
try {
return await serveDir(request, {
fsRoot: config.publicDir,
quiet: true,
});
} catch {
// Fallback to index.html for SPA routing
try {
return await serveDir(new Request(new URL('/index.html', url)), {
fsRoot: config.publicDir,
quiet: true,
});
} catch {
return new Response('Not found', { status: 404 });
}
}
};
// Start server
logger.info(`Server starting on http://${config.host}:${config.port}`);
await serve(handler, {
hostname: config.host,
port: config.port,
});
} catch (error) {
logger.error(`Failed to start server: ${error}`);
Deno.exit(1);
}
}
// Handle graceful shutdown
Deno.addSignalListener('SIGINT', () => {
logger.info('Received SIGINT, shutting down gracefully...');
Deno.exit(0);
});
// SIGTERM is not supported on Windows, only add listener on Unix-like systems
if (Deno.build.os !== 'windows') {
Deno.addSignalListener('SIGTERM', () => {
logger.info('Received SIGTERM, shutting down gracefully...');
Deno.exit(0);
});
}
// Run the application
if (import.meta.main) {
main();
}

View File

@@ -0,0 +1,122 @@
import { Application, Router, send } from '@oak/oak';
import type { AppConfig } from '../shared/config.ts';
import { logger } from '../shared/logger.ts';
import { createTrackRoutes } from './routes/trackRoutes.ts';
import { createAudioRoutes } from './routes/audioRoutes.ts';
import type { TrackService } from '../application/mod.ts';
import type { AudioStreamingService, CoverArtService } from '../infrastructure/mod.ts';
/**
* HTTP server using Oak
*/
export class HttpServer {
private readonly app: Application;
constructor(
private readonly config: AppConfig,
private readonly trackService: TrackService,
private readonly audioStreaming: AudioStreamingService,
private readonly coverArt: CoverArtService,
) {
this.app = new Application();
this.setupMiddleware();
this.setupRoutes();
}
/**
* Setup middleware
*/
private setupMiddleware(): void {
// Error handling
this.app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
logger.error(`Request error: ${error}`);
ctx.response.status = 500;
ctx.response.body = { error: 'Internal server error' };
}
});
// Logging
this.app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
logger.info(`${ctx.request.method} ${ctx.request.url.pathname} - ${ctx.response.status} - ${ms}ms`);
});
// CORS
this.app.use(async (ctx, next) => {
ctx.response.headers.set('Access-Control-Allow-Origin', this.config.corsOrigin);
ctx.response.headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
ctx.response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Range');
ctx.response.headers.set('Access-Control-Expose-Headers', 'Content-Range, Content-Length, Accept-Ranges');
if (ctx.request.method === 'OPTIONS') {
ctx.response.status = 204;
return;
}
await next();
});
}
/**
* Setup routes
*/
private setupRoutes(): void {
const router = new Router();
// API routes
const trackRoutes = createTrackRoutes(this.trackService);
const audioRoutes = createAudioRoutes(this.audioStreaming, this.coverArt, this.config);
router.use(trackRoutes.routes(), trackRoutes.allowedMethods());
router.use(audioRoutes.routes(), audioRoutes.allowedMethods());
this.app.use(router.routes());
this.app.use(router.allowedMethods());
// Static file serving (public directory)
this.app.use(async (ctx) => {
try {
await send(ctx, ctx.request.url.pathname, {
root: this.config.publicDir,
index: 'index.html',
});
} catch {
// If file not found, serve index.html for SPA routing
try {
await send(ctx, '/index.html', {
root: this.config.publicDir,
});
} catch (error) {
ctx.response.status = 404;
ctx.response.body = 'Not found';
}
}
});
}
/**
* Start the HTTP server
*/
async listen(): Promise<void> {
const { host, port } = this.config;
logger.info(`Starting HTTP server on http://${host}:${port}`);
await this.app.listen({
hostname: host,
port,
});
}
/**
* Get the underlying Oak application
*/
getApp(): Application {
return this.app;
}
}

View File

@@ -0,0 +1,798 @@
import { Server as SocketIOServer } from 'https://deno.land/x/socket_io@0.2.0/mod.ts';
import type { Socket } from 'https://deno.land/x/socket_io@0.2.0/mod.ts';
import type { RoomModel, PlayerModel } from '../domain/models/mod.ts';
import { GameService, RoomService } from '../application/mod.ts';
import { WS_EVENTS, HELLO_TIMER_MS, SYNC_INTERVAL_MS } from '../shared/constants.ts';
import { GamePhase, GameStatus } from '../domain/types.ts';
import { logger } from '../shared/logger.ts';
/**
* WebSocket game server using Socket.IO
*/
export class WebSocketServer {
private io!: SocketIOServer;
private syncTimers: Map<string, number> = new Map();
private playerSockets: Map<string, Socket> = new Map(); // Map player ID to socket
constructor(
private readonly roomService: RoomService,
private readonly gameService: GameService,
) { }
/**
* Initialize Socket.IO server
*/
initialize(corsOrigin: string = '*'): void {
this.io = new SocketIOServer({
cors: {
origin: corsOrigin,
methods: ['GET', 'POST'],
},
});
this.io.on('connection', (socket: Socket) => {
this.handleConnection(socket);
});
logger.info('WebSocket server initialized');
}
/**
* Get the Socket.IO handler for Deno serve
*/
getHandler(): (request: Request, connInfo: { localAddr: Deno.Addr; remoteAddr: Deno.Addr }) => Response | Promise<Response> {
if (!this.io) {
throw new Error('WebSocket server not initialized');
}
return this.io.handler();
}
/**
* Handle new connection
*/
private handleConnection(socket: Socket): void {
logger.info(`Client connected: ${socket.id}`);
const { player, sessionId } = this.roomService.createPlayer();
let helloSent = false;
// Track the socket for this player
this.playerSockets.set(player.id, socket);
const sendHello = () => {
if (helloSent) return;
helloSent = true;
socket.emit('message', {
type: WS_EVENTS.CONNECTED,
playerId: player.id,
sessionId,
});
};
// Delay hello to allow resume attempts
const helloTimer = setTimeout(sendHello, HELLO_TIMER_MS);
// Setup message handlers
socket.on('message', (msg: any) => {
this.handleMessage(socket, msg, player, () => clearTimeout(helloTimer));
});
socket.on('disconnect', () => {
this.handleDisconnect(player);
this.playerSockets.delete(player.id); // Remove socket tracking
clearTimeout(helloTimer);
});
socket.on('error', (error: Error) => {
logger.error(`Socket error for ${socket.id}: ${error}`);
});
}
/**
* Handle incoming message
*/
private async handleMessage(
socket: Socket,
msg: any,
player: PlayerModel,
clearHelloTimer: () => void,
): Promise<void> {
if (!msg || typeof msg !== 'object' || !msg.type) return;
try {
switch (msg.type) {
case WS_EVENTS.RESUME:
this.handleResume(socket, msg, clearHelloTimer, player.id);
break;
case WS_EVENTS.CREATE_ROOM:
await this.handleCreateRoom(socket, msg, player);
break;
case WS_EVENTS.JOIN_ROOM:
this.handleJoinRoom(socket, msg, player);
break;
case WS_EVENTS.LEAVE_ROOM:
this.handleLeaveRoom(player);
break;
case WS_EVENTS.SET_NAME:
this.handleSetName(msg, player);
break;
case WS_EVENTS.READY:
this.handleReady(msg, player);
break;
case WS_EVENTS.SELECT_PLAYLIST:
this.handleSelectPlaylist(msg, player);
break;
case WS_EVENTS.SET_GOAL:
this.handleSetGoal(msg, player);
break;
case WS_EVENTS.START_GAME:
await this.handleStartGame(player);
break;
case WS_EVENTS.GUESS:
this.handleGuess(msg, player);
break;
case 'submit_answer':
await this.handleSubmitAnswer(socket, msg, player);
break;
case 'place_guess':
await this.handlePlaceGuess(socket, msg, player);
break;
case WS_EVENTS.PAUSE:
this.handlePause(player);
break;
case WS_EVENTS.RESUME_PLAY:
this.handleResumePlay(player);
break;
case WS_EVENTS.SKIP_TRACK:
await this.handleSkipTrack(player);
break;
case WS_EVENTS.SET_SPECTATOR:
this.handleSetSpectator(msg, player);
break;
case WS_EVENTS.KICK_PLAYER:
this.handleKickPlayer(msg, player);
break;
case 'rematch':
this.handleRematch(player);
break;
case 'reaction':
this.handleReaction(msg, player);
break;
default:
logger.debug(`Unknown message type: ${msg.type}`);
}
} catch (error) {
logger.error(`Error handling message ${msg.type}: ${error}`);
socket.emit('message', {
type: WS_EVENTS.ERROR,
error: 'Failed to process request',
});
}
}
/**
* Resume existing session
*/
private handleResume(socket: Socket, msg: any, clearHelloTimer: () => void, newPlayerId: string): void {
clearHelloTimer();
const sessionId = msg.sessionId;
if (!sessionId) {
socket.emit('message', {
type: WS_EVENTS.RESUME_RESULT,
ok: false,
reason: 'no_session',
});
return;
}
const existingPlayer = this.roomService.resumePlayer(sessionId);
if (!existingPlayer) {
socket.emit('message', {
type: WS_EVENTS.RESUME_RESULT,
ok: false,
reason: 'session_not_found',
});
return;
}
existingPlayer.setConnected(true);
// Remove the temporary player that was created in handleConnection
this.playerSockets.delete(newPlayerId);
// Update socket mapping to point to the resumed player
this.playerSockets.set(existingPlayer.id, socket);
socket.emit('message', {
type: WS_EVENTS.RESUME_RESULT,
ok: true,
playerId: existingPlayer.id,
sessionId: existingPlayer.sessionId,
});
// Send room update if in a room
if (existingPlayer.roomId) {
const room = this.roomService.getRoom(existingPlayer.roomId);
if (room) {
this.broadcastRoomUpdate(room);
}
}
}
/**
* Create new room
*/
private async handleCreateRoom(socket: Socket, msg: any, player: PlayerModel): Promise<void> {
const { room } = this.roomService.createRoomWithPlayer(
player,
msg.name,
msg.goal || 10,
);
socket.emit('message', {
type: 'room_created',
room: room.toSummary(),
});
this.broadcastRoomUpdate(room);
}
/**
* Join existing room
*/
private handleJoinRoom(socket: Socket, msg: any, player: PlayerModel): void {
const roomId = msg.roomId;
if (!roomId) {
socket.emit('message', {
type: WS_EVENTS.ERROR,
error: 'Room ID required',
});
return;
}
try {
const { room } = this.roomService.joinRoomWithPlayer(roomId, player);
socket.emit('message', {
type: 'room_joined',
room: room.toSummary(),
});
this.broadcastRoomUpdate(room);
} catch (error) {
socket.emit('message', {
type: WS_EVENTS.ERROR,
error: `Failed to join room: ${error}`,
});
}
}
/**
* Leave room
*/
private handleLeaveRoom(player: PlayerModel): void {
if (player.roomId) {
const room = this.roomService.getRoom(player.roomId);
this.roomService.leaveRoom(player.id);
if (room) {
this.broadcastRoomUpdate(room);
}
}
}
/**
* Set player name
*/
private handleSetName(msg: any, player: PlayerModel): void {
if (msg.name) {
this.roomService.setPlayerName(player.id, msg.name);
if (player.roomId) {
const room = this.roomService.getRoom(player.roomId);
if (room) {
this.broadcastRoomUpdate(room);
}
}
}
}
/**
* Set ready status
*/
private handleReady(msg: any, player: PlayerModel): void {
if (!player.roomId) return;
const room = this.roomService.getRoom(player.roomId);
if (!room) return;
room.state.setReady(player.id, !!msg.ready);
this.broadcastRoomUpdate(room);
}
/**
* Select playlist
*/
private handleSelectPlaylist(msg: any, player: PlayerModel): void {
if (!player.roomId) return;
const room = this.roomService.getRoom(player.roomId);
if (!room || room.hostId !== player.id) return;
room.state.playlist = msg.playlist || 'default';
this.broadcastRoomUpdate(room);
}
/**
* Set goal (win condition)
*/
private handleSetGoal(msg: any, player: PlayerModel): void {
if (!player.roomId) return;
const room = this.roomService.getRoom(player.roomId);
if (!room || room.hostId !== player.id) return;
// Only allow changing goal in lobby
if (room.state.status !== 'lobby') return;
const goal = parseInt(msg.goal, 10);
if (isNaN(goal) || goal < 1 || goal > 50) return;
room.state.goal = goal;
logger.info(`Room ${room.id}: Goal set to ${goal} by host ${player.id}`);
this.broadcastRoomUpdate(room);
}
/**
* Start game
*/
private async handleStartGame(player: PlayerModel): Promise<void> {
if (!player.roomId) return;
const room = this.roomService.getRoom(player.roomId);
if (!room || room.hostId !== player.id) return;
try {
await this.gameService.startGame(room);
await this.drawNextTrack(room);
this.startSyncTimer(room);
} catch (error) {
logger.error(`Failed to start game: ${error}`);
this.broadcast(room, WS_EVENTS.ERROR, { error: `Failed to start game: ${error}` });
}
}
/**
* Handle guess
*/
private handleGuess(msg: any, player: PlayerModel): void {
if (!player.roomId) return;
const room = this.roomService.getRoom(player.roomId);
if (!room) return;
try {
const result = this.gameService.processGuess(
room,
player.id,
msg.guess,
msg.guessType || 'title',
);
this.broadcast(room, WS_EVENTS.GUESS_RESULT, { result });
this.broadcastRoomUpdate(room);
} catch (error) {
logger.error(`Guess error: ${error}`);
}
}
/**
* Handle submit answer (title + artist guess)
*/
private async handleSubmitAnswer(socket: Socket, msg: any, player: PlayerModel): Promise<void> {
if (!player.roomId) return;
const room = this.roomService.getRoom(player.roomId);
if (!room || room.state.status !== 'playing' || room.state.phase !== 'guess') {
socket.emit('message', {
type: 'answer_result',
ok: false,
error: 'not_accepting',
});
return;
}
const guess = msg.guess || {};
const title = String(guess.title || '').trim();
const artist = String(guess.artist || '').trim();
if (!title || !artist) {
socket.emit('message', {
type: 'answer_result',
ok: false,
error: 'invalid',
});
return;
}
try {
const result = await this.gameService.checkTitleArtistGuess(room, player.id, title, artist);
socket.emit('message', {
type: 'answer_result',
ok: true,
correctTitle: result.titleCorrect,
correctArtist: result.artistCorrect,
awarded: result.awarded,
alreadyAwarded: result.alreadyAwarded,
});
this.broadcastRoomUpdate(room);
} catch (error) {
logger.error(`Submit answer error: ${error}`);
socket.emit('message', {
type: 'answer_result',
ok: false,
error: String(error),
});
}
}
/**
* Handle place guess (timeline slot placement)
*/
private async handlePlaceGuess(socket: Socket, msg: any, player: PlayerModel): Promise<void> {
if (!player.roomId) return;
const room = this.roomService.getRoom(player.roomId);
if (!room || room.state.status !== 'playing' || room.state.phase !== 'guess') {
socket.emit('message', {
type: WS_EVENTS.ERROR,
error: 'not_accepting',
});
return;
}
if (room.state.currentGuesser !== player.id) {
socket.emit('message', {
type: WS_EVENTS.ERROR,
error: 'not_your_turn',
});
return;
}
const slot = typeof msg.slot === 'number' ? msg.slot : null;
if (slot === null) {
socket.emit('message', {
type: WS_EVENTS.ERROR,
error: 'invalid_slot',
});
return;
}
// Check if player wants to use tokens for guaranteed placement
const useTokens = !!msg.useTokens;
try {
const result = await this.gameService.placeInTimeline(room, player.id, slot, useTokens);
// Store the result
room.state.lastResult = {
playerId: player.id,
correct: result.correct,
guess: null,
type: 'placement',
tokensUsed: result.tokensUsed,
};
// Move to reveal phase - DON'T auto-skip, wait for user to click next
room.state.phase = GamePhase.REVEAL;
// Broadcast reveal with track info and result
this.broadcast(room, 'reveal', {
result: room.state.lastResult,
track: room.state.currentTrack,
});
this.broadcastRoomUpdate(room);
// Check if player won
const timeline = room.state.timeline[player.id] || [];
if (result.correct && timeline.length >= room.state.goal) {
room.state.status = GameStatus.ENDED;
const winnerPlayer = room.players.get(player.id);
this.broadcast(room, WS_EVENTS.GAME_ENDED, {
winner: player.id,
winnerName: winnerPlayer?.name || player.id.slice(0, 4),
score: timeline.length,
timeline: timeline,
});
}
} catch (error) {
logger.error(`Place guess error: ${error}`);
socket.emit('message', {
type: WS_EVENTS.ERROR,
error: String(error),
});
}
}
/**
* Pause game
*/
private handlePause(player: PlayerModel): void {
if (!player.roomId) return;
const room = this.roomService.getRoom(player.roomId);
if (!room || room.state.status !== 'playing' || room.state.phase !== 'guess') return;
if (room.state.currentGuesser !== player.id) return;
if (!room.state.currentTrack) return;
if (!room.state.paused) {
this.gameService.pauseGame(room);
this.stopSyncTimer(room);
}
// Broadcast control event to all players
this.broadcast(room, 'control', { action: 'pause' });
this.broadcastRoomUpdate(room);
}
/**
* Resume play
*/
private handleResumePlay(player: PlayerModel): void {
if (!player.roomId) return;
const room = this.roomService.getRoom(player.roomId);
if (!room || room.state.status !== 'playing' || room.state.phase !== 'guess') return;
if (room.state.currentGuesser !== player.id) return;
if (!room.state.currentTrack) return;
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;
this.startSyncTimer(room);
// Broadcast control event with timing info
this.broadcast(room, 'control', {
action: 'play',
startAt: room.state.trackStartAt,
serverNow: now,
});
this.broadcastRoomUpdate(room);
}
/**
* Skip track
*/
private async handleSkipTrack(player: PlayerModel): Promise<void> {
if (!player.roomId) return;
const room = this.roomService.getRoom(player.roomId);
if (!room) return;
await this.drawNextTrack(room);
}
/**
* Set spectator mode
*/
private handleSetSpectator(msg: any, player: PlayerModel): void {
if (!player.roomId) return;
const room = this.roomService.getRoom(player.roomId);
if (!room) return;
room.state.setSpectator(player.id, !!msg.spectator);
this.broadcastRoomUpdate(room);
}
/**
* Kick player
*/
private handleKickPlayer(msg: any, player: PlayerModel): void {
if (!player.roomId) return;
const room = this.roomService.getRoom(player.roomId);
if (!room || room.hostId !== player.id) return;
const targetId = msg.playerId;
if (targetId && targetId !== player.id) {
this.roomService.leaveRoom(targetId);
this.broadcastRoomUpdate(room);
}
}
/**
* Handle disconnect
*/
private handleDisconnect(player: PlayerModel): void {
logger.info(`Player disconnected: ${player.id}`);
this.roomService.setPlayerConnected(player.id, false);
if (player.roomId) {
const room = this.roomService.getRoom(player.roomId);
if (room) {
this.broadcastRoomUpdate(room);
}
}
}
/**
* Handle rematch request - reset game to lobby with same settings
*/
private handleRematch(player: PlayerModel): void {
if (!player.roomId) return;
const room = this.roomService.getRoom(player.roomId);
if (!room) return;
// Only host can initiate rematch
if (room.hostId !== player.id) {
logger.debug(`Non-host ${player.id} tried to initiate rematch`);
return;
}
// Stop any sync timers
this.stopSyncTimer(room);
// Reset game state to lobby
room.state.status = GameStatus.LOBBY;
room.state.phase = GamePhase.GUESS;
room.state.currentGuesser = null;
room.state.currentTrack = null;
room.state.lastResult = null;
room.state.trackStartAt = null;
room.state.paused = false;
room.state.pausedPosSec = 0;
room.state.titleArtistAwardedThisRound = {};
// Clear all player timelines, tokens, and ready states
for (const playerId of room.players.keys()) {
room.state.timeline[playerId] = [];
room.state.tokens[playerId] = 0;
room.state.ready[playerId] = false;
}
// Keep playlist and goal settings intact
// room.state.playlist stays the same
// room.state.goal stays the same
// Reset deck (will be refilled when game starts again)
room.deck = [];
room.discard = [];
logger.info(`Room ${room.id}: Rematch initiated by host ${player.id}`);
this.broadcastRoomUpdate(room);
}
/**
* Handle reaction - broadcast emoji to all players in room
*/
private handleReaction(msg: any, player: PlayerModel): void {
if (!player.roomId) return;
const room = this.roomService.getRoom(player.roomId);
if (!room) return;
const emoji = msg.emoji;
if (!emoji || typeof emoji !== 'string') return;
// Broadcast reaction to all players
this.broadcast(room, 'reaction', {
emoji,
playerId: player.id,
playerName: player.name,
});
}
/**
* Draw next track
*/
private async drawNextTrack(room: RoomModel): Promise<void> {
const track = await this.gameService.drawNextTrack(room);
if (!track) {
const winnerId = this.gameService.getWinner(room);
const winnerPlayer = winnerId ? room.players.get(winnerId) : null;
const winnerTimeline = winnerId ? (room.state.timeline[winnerId] || []) : [];
this.broadcast(room, WS_EVENTS.GAME_ENDED, {
winner: winnerId,
winnerName: winnerPlayer?.name || (winnerId ? winnerId.slice(0, 4) : 'Unknown'),
score: winnerTimeline.length,
timeline: winnerTimeline,
});
this.stopSyncTimer(room);
return;
}
// Rotate to next player
this.gameService.nextTurn(room);
this.broadcast(room, WS_EVENTS.PLAY_TRACK, {
track,
startAt: room.state.trackStartAt,
serverNow: Date.now(),
});
this.broadcastRoomUpdate(room);
}
/**
* Start sync timer for time synchronization
*/
private startSyncTimer(room: RoomModel): void {
this.stopSyncTimer(room);
const timer = setInterval(() => {
if (room.state.status !== 'playing' || !room.state.trackStartAt || room.state.paused) {
return;
}
this.broadcast(room, WS_EVENTS.SYNC, {
startAt: room.state.trackStartAt,
serverNow: Date.now(),
});
}, SYNC_INTERVAL_MS);
this.syncTimers.set(room.id, timer as unknown as number);
}
/**
* Stop sync timer
*/
private stopSyncTimer(room: RoomModel): void {
const timer = this.syncTimers.get(room.id);
if (timer) {
clearInterval(timer);
this.syncTimers.delete(room.id);
}
}
/**
* Broadcast to all players in room
*/
private broadcast(room: RoomModel, type: string, payload: any): void {
for (const player of room.players.values()) {
const socket = this.playerSockets.get(player.id);
if (socket) {
socket.emit('message', { type, ...payload });
logger.debug(`Broadcasting ${type} to player ${player.id}`);
} else {
logger.debug(`No socket found for player ${player.id}`);
}
}
}
/**
* Broadcast room update
*/
private broadcastRoomUpdate(room: RoomModel): void {
this.broadcast(room, WS_EVENTS.ROOM_UPDATE, { room: room.toSummary() });
}
}

View File

@@ -0,0 +1,107 @@
import { Router } from '@oak/oak';
import type { Context } from '@oak/oak';
import { AudioStreamingService, CoverArtService } from '../../infrastructure/mod.ts';
import type { AppConfig } from '../../shared/config.ts';
import { NotFoundError } from '../../shared/errors.ts';
import { logger } from '../../shared/logger.ts';
/**
* Audio streaming routes
*/
export function createAudioRoutes(
audioStreaming: AudioStreamingService,
coverArt: CoverArtService,
config: AppConfig,
): Router {
const router = new Router();
/**
* HEAD /audio/t/:token
* Check audio file availability by token
*/
router.head('/audio/t/:token', async (ctx: Context) => {
try {
const token = ctx.params.token;
if (!token) {
throw new NotFoundError('Token required');
}
await audioStreaming.handleHeadRequest(ctx, token);
} catch (error) {
logger.error(`HEAD audio error: ${error}`);
ctx.response.status = error instanceof NotFoundError ? 404 : 500;
}
});
/**
* GET /audio/t/:token
* Stream audio file by token (with range support)
*/
router.get('/audio/t/:token', async (ctx: Context) => {
try {
const token = ctx.params.token;
if (!token) {
throw new NotFoundError('Token required');
}
await audioStreaming.handleStreamRequest(ctx, token);
} catch (error) {
logger.error(`GET audio error: ${error}`);
ctx.response.body = 'Not found';
ctx.response.status = error instanceof NotFoundError ? 404 : 500;
}
});
/**
* GET /cover/:name(*)
* Get cover art from audio file
*/
router.get('/cover/:name(.*)', async (ctx: Context) => {
try {
const fileName = ctx.params.name;
if (!fileName) {
throw new NotFoundError('Filename required');
}
// Try to get cover art
const cover = await coverArt.getCoverArt(fileName);
if (!cover) {
ctx.response.status = 404;
ctx.response.body = 'No cover art found';
return;
}
ctx.response.headers.set('Content-Type', cover.mime);
ctx.response.headers.set('Content-Length', String(cover.buf.length));
ctx.response.headers.set('Cache-Control', 'public, max-age=3600');
ctx.response.body = cover.buf;
ctx.response.status = 200;
} catch (error) {
logger.error(`GET cover error: ${error}`);
ctx.response.status = error instanceof NotFoundError ? 404 : 500;
ctx.response.body = 'Error fetching cover art';
}
});
// Only enable name-based endpoint if debug mode is on
if (config.audioDebugNames) {
logger.warn('Audio debug mode enabled - name-based endpoint active (security risk)');
router.get('/audio/:name', async (ctx: Context) => {
try {
const fileName = ctx.params.name;
if (!fileName) {
throw new NotFoundError('Filename required');
}
const token = await audioStreaming.createAudioToken(fileName);
await audioStreaming.handleStreamRequest(ctx, token);
} catch (error) {
logger.error(`GET audio by name error: ${error}`);
ctx.response.status = error instanceof NotFoundError ? 404 : 500;
ctx.response.body = 'Not found';
}
});
}
return router;
}

View File

@@ -0,0 +1,69 @@
import { Router } from '@oak/oak';
import type { Context } from '@oak/oak';
import { TrackService } from '../../application/mod.ts';
import { logger } from '../../shared/logger.ts';
/**
* Track/Playlist routes
*/
export function createTrackRoutes(trackService: TrackService): Router {
const router = new Router();
/**
* GET /api/playlists
* Get list of available playlists
*/
router.get('/api/playlists', async (ctx: Context) => {
try {
const playlists = await trackService.getAvailablePlaylists();
ctx.response.body = { ok: true, playlists };
ctx.response.status = 200;
} catch (error) {
logger.error(`Error fetching playlists: ${error}`);
ctx.response.body = { ok: false, error: 'Failed to fetch playlists' };
ctx.response.status = 500;
}
});
/**
* GET /api/tracks?playlist=<id>
* Get tracks from a specific playlist
*/
router.get('/api/tracks', async (ctx: Context) => {
try {
const playlistId = ctx.request.url.searchParams.get('playlist') || 'default';
const tracks = await trackService.loadPlaylistTracks(playlistId);
ctx.response.body = { ok: true, tracks, playlist: playlistId };
ctx.response.status = 200;
} catch (error) {
logger.error(`Error fetching tracks: ${error}`);
ctx.response.body = { ok: false, error: 'Failed to fetch tracks' };
ctx.response.status = 500;
}
});
/**
* GET /api/reload-years?playlist=<id>
* Reload years index for a playlist
*/
router.get('/api/reload-years', async (ctx: Context) => {
try {
const playlistId = ctx.request.url.searchParams.get('playlist') || 'default';
const result = await trackService.reloadYearsIndex(playlistId);
ctx.response.body = {
ok: true,
count: result.count,
playlist: playlistId
};
ctx.response.status = 200;
} catch (error) {
logger.error(`Error reloading years: ${error}`);
ctx.response.body = { ok: false, error: 'Failed to reload years' };
ctx.response.status = 500;
}
});
return router;
}

View File

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 245 KiB

View File

@@ -6,7 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hitstar Web</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="/socket.io/socket.io.js"></script>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/howler/2.2.4/howler.min.js"></script>
<style>
@keyframes record-spin {
from {
@@ -31,6 +32,80 @@
#dashboard[open] .dashboard-chevron {
transform: rotate(90deg);
}
/* Winner popup animations */
@keyframes confetti-fall {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
@keyframes popup-entrance {
0% {
transform: scale(0.5) translateY(20px);
opacity: 0;
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1) translateY(0);
opacity: 1;
}
}
@keyframes bounce-slow {
0%,
100% {
transform: translateX(-50%) translateY(0);
}
50% {
transform: translateX(-50%) translateY(-10px);
}
}
.animate-popup {
animation: popup-entrance 0.5s ease-out forwards;
}
.animate-bounce-slow {
animation: bounce-slow 1.5s ease-in-out infinite;
}
/* Live reactions animation */
@keyframes reaction-rise {
0% {
transform: translateX(-50%) translateY(0) scale(1);
opacity: 1;
}
100% {
transform: translateX(-50%) translateY(-200px) scale(1.2);
opacity: 0;
}
}
.reaction-btn {
transition: transform 0.15s ease;
}
.reaction-btn:hover {
transform: scale(1.3);
}
.reaction-btn:active {
transform: scale(0.9);
}
</style>
</head>
@@ -48,6 +123,10 @@
Code kopiert!
</div>
<!-- Reaction Container (for floating emojis) -->
<div id="reactionContainer"
class="fixed bottom-32 sm:bottom-20 left-0 right-0 h-48 sm:h-64 pointer-events-none z-40 overflow-hidden"></div>
<!-- Lobby Card -->
<div id="lobby"
class="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-4 md:p-6 space-y-4">
@@ -129,15 +208,33 @@
<!-- Playlist Selection (only shown in lobby for host) -->
<div id="playlistSection"
class="hidden rounded-lg border border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-800/60 p-4">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
🎵 Playlist auswählen
</label>
<select id="playlistSelect"
class="w-full h-11 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 outline-none focus:ring-2 focus:ring-indigo-500">
<option value="default">Lade Playlists...</option>
</select>
<p class="mt-2 text-xs text-slate-500 dark:text-slate-400">
Als Host kannst du die Playlist für dieses Spiel wählen.
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Playlist Selection -->
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
🎵 Playlist auswählen
</label>
<select id="playlistSelect"
class="w-full h-11 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 outline-none focus:ring-2 focus:ring-indigo-500">
<option value="default">Lade Playlists...</option>
</select>
</div>
<!-- Goal/Score Selection -->
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
🏆 Siegpunkte
</label>
<select id="goalSelect"
class="w-full h-11 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 outline-none focus:ring-2 focus:ring-indigo-500">
<option value="5">5 Karten</option>
<option value="10" selected>10 Karten</option>
<option value="15">15 Karten</option>
<option value="20">20 Karten</option>
</select>
</div>
</div>
<p class="mt-3 text-xs text-slate-500 dark:text-slate-400">
Als Host kannst du die Playlist und Siegpunkte für dieses Spiel wählen.
</p>
</div>
@@ -158,6 +255,11 @@
<span class="font-medium text-slate-500 dark:text-slate-400">Playlist:</span>
<span id="currentPlaylist" class="font-semibold text-slate-900 dark:text-slate-100">default</span>
</div>
<div id="goalInfoSection" class="text-slate-700 dark:text-slate-300 flex items-center gap-1.5">
<span class="text-base">🏆</span>
<span class="font-medium text-slate-500 dark:text-slate-400">Ziel:</span>
<span id="goalInfo" class="font-semibold text-slate-900 dark:text-slate-100">10 Karten</span>
</div>
</div>
<div class="flex flex-wrap items-center gap-3 justify-start sm:justify-end">
<label class="inline-flex items-center gap-3 text-slate-700 dark:text-slate-300 select-none">
@@ -186,7 +288,7 @@
<div id="revealBanner" class="hidden"></div>
</div>
<div class="mt-3">
<audio id="audio" preload="none" class="hidden"></audio>
<!-- Audio element removed - using Howler.js now -->
<div class="flex flex-col items-center">
<!-- Record Disc -->
<div class="relative" style="width: 200px; height: 200px">
@@ -227,10 +329,15 @@
</div>
<!-- Volume (available to all players) -->
<div class="mt-3">
<div class="mt-3 flex flex-col sm:flex-row gap-4">
<label class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
Lautstärke
<input id="volumeSlider" type="range" min="0" max="1" step="0.01" class="w-40 accent-indigo-600" />
🎵 Musik
<input id="volumeSlider" type="range" min="0" max="1" step="0.01" class="w-32 accent-indigo-600" />
</label>
<label class="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
🔊 SFX
<input id="sfxVolumeSlider" type="range" min="0" max="1" step="0.01" value="0.7"
class="w-32 accent-emerald-600" />
</label>
</div>
</div>
@@ -270,17 +377,26 @@
<h3 class="text-lg font-semibold">Position</h3>
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Wähle die Position und klicke Platzieren.</p>
<div class="flex flex-wrap items-center gap-3">
<div id="placeArea" class="hidden flex items-center gap-2">
<select id="slotSelect"
class="h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3"></select>
<button id="placeBtn"
class="h-10 px-4 rounded-lg bg-slate-900 hover:bg-slate-700 text-white font-medium dark:bg-slate-700 dark:hover:bg-slate-600">
Platzieren
</button>
<div id="placeArea" class="hidden w-full">
<div class="flex flex-col sm:flex-row gap-2 w-full">
<select id="slotSelect"
class="h-10 rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-800 px-3 w-full sm:flex-1 sm:min-w-0"></select>
<div class="flex gap-2 w-full sm:w-auto">
<button id="placeBtn"
class="h-10 px-4 rounded-lg bg-slate-900 hover:bg-slate-700 text-white font-medium dark:bg-slate-700 dark:hover:bg-slate-600 flex-1 sm:flex-none">
Platzieren
</button>
<button id="placeWithTokensBtn"
class="h-10 px-3 rounded-lg bg-amber-500 hover:bg-amber-600 text-white font-medium flex items-center justify-center gap-1 whitespace-nowrap flex-1 sm:flex-none disabled:opacity-50 disabled:cursor-not-allowed"
title="Nutze 3 Tokens um die Karte automatisch richtig zu platzieren">
🪙 3 Tokens
</button>
</div>
</div>
</div>
<div id="nextArea" class="hidden">
<div id="nextArea" class="hidden w-full sm:w-auto">
<button id="nextBtn"
class="h-10 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium">
class="h-10 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white font-medium w-full sm:w-auto">
Next
</button>
</div>
@@ -295,6 +411,27 @@
</div>
</div>
<!-- Reaction Bar -->
<div id="reactionBar" class="hidden mt-4 flex flex-wrap items-center justify-center gap-1 sm:gap-2">
<span
class="text-xs sm:text-sm text-slate-500 dark:text-slate-400 mr-1 sm:mr-2 w-full sm:w-auto text-center mb-1 sm:mb-0">Reaktionen:</span>
<button
class="reaction-btn text-xl sm:text-2xl p-1.5 sm:p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg active:bg-slate-200 dark:active:bg-slate-700"
data-emoji="😂" title="Lustig">😂</button>
<button
class="reaction-btn text-xl sm:text-2xl p-1.5 sm:p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg active:bg-slate-200 dark:active:bg-slate-700"
data-emoji="😱" title="Schock">😱</button>
<button
class="reaction-btn text-xl sm:text-2xl p-1.5 sm:p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg active:bg-slate-200 dark:active:bg-slate-700"
data-emoji="🔥" title="Feuer">🔥</button>
<button
class="reaction-btn text-xl sm:text-2xl p-1.5 sm:p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg active:bg-slate-200 dark:active:bg-slate-700"
data-emoji="👏" title="Applaus">👏</button>
<button
class="reaction-btn text-xl sm:text-2xl p-1.5 sm:p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg active:bg-slate-200 dark:active:bg-slate-700"
data-emoji="🎉" title="Party">🎉</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,293 @@
import { $bufferBadge, $progressFill, $recordDisc } from "./dom.js";
import { state } from "./state.js";
// Howler.js audio instance
let currentSound = null;
let progressInterval = null;
let isInitialized = false;
/**
* Get or create the current Howler sound instance
*/
export function getSound() {
return currentSound;
}
/**
* Load and prepare a new track
* @param {string} url - The URL to load
* @param {string} [fileName] - Optional filename to extract format from
*/
export function loadTrack(url, fileName) {
// Unload previous sound
if (currentSound) {
currentSound.unload();
currentSound = null;
}
// Clear progress interval
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
// Determine format from filename if provided
let format = null;
if (fileName) {
const ext = fileName.slice(fileName.lastIndexOf(".") + 1).toLowerCase();
// Map common extensions to Howler format names
const formatMap = {
mp3: "mp3",
opus: "opus",
ogg: "ogg",
wav: "wav",
m4a: "m4a",
aac: "aac",
};
format = formatMap[ext] || ext;
}
// Detect iOS/Safari for optimized settings
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
// iOS ignores programmatic volume, so always use 1.0 for iOS
const initialVolume = isIOS
? 1.0
: parseFloat(localStorage.getItem("volume") || "1");
// Create new Howler sound
const howlConfig = {
src: [url],
// Always use HTML5 Audio for streaming, but optimize for iOS
html5: true,
preload: true,
autoplay: false, // Explicitly prevent autoplay
volume: initialVolume,
// Enable pool for better resource management on iOS
pool: isIOS ? 1 : 5,
onload: function () {
showBuffer(false);
},
onloaderror: function (id, error) {
console.error("Error loading audio:", error);
showBuffer(false);
},
onplayerror: function (id, error) {
console.error("Error playing audio:", error);
// iOS Safari often requires user interaction to unlock audio
currentSound.once("unlock", function () {
currentSound.play();
});
},
onplay: function () {
showBuffer(false);
if ($recordDisc) $recordDisc.classList.add("spin-record");
startProgressTracking();
},
onpause: function () {
if ($recordDisc) $recordDisc.classList.remove("spin-record");
stopProgressTracking();
},
onend: function () {
if ($recordDisc) $recordDisc.classList.remove("spin-record");
stopProgressTracking();
},
onstop: function () {
if ($recordDisc) $recordDisc.classList.remove("spin-record");
stopProgressTracking();
},
};
// Add format if detected
if (format) {
howlConfig.format = [format];
}
currentSound = new Howl(howlConfig);
return currentSound;
}
/**
* Start tracking playback progress
*/
function startProgressTracking() {
if (progressInterval) return;
progressInterval = setInterval(() => {
if (!currentSound || !currentSound.playing()) {
stopProgressTracking();
return;
}
const duration = currentSound.duration() || 0;
const seek = currentSound.seek() || 0;
if (duration > 0 && $progressFill) {
const pct = Math.min(100, Math.max(0, (seek / duration) * 100));
$progressFill.style.width = pct + "%";
}
}, 100); // Update every 100ms for smooth progress
}
/**
* Stop tracking playback progress
*/
function stopProgressTracking() {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
}
/**
* Show/hide buffering indicator
*/
function showBuffer(visible) {
state.isBuffering = visible;
if ($bufferBadge) $bufferBadge.classList.toggle("hidden", !visible);
if ($recordDisc && !visible && currentSound?.playing()) {
$recordDisc.classList.add("spin-record");
} else if ($recordDisc && visible) {
$recordDisc.classList.remove("spin-record");
}
}
/**
* Initialize audio UI and Howler settings
*/
export function initAudioUI() {
if (isInitialized) return;
isInitialized = true;
// Detect iOS/Safari
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
// Set up volume control if it exists
const $volumeSlider = document.getElementById("volumeSlider");
if ($volumeSlider) {
const savedVolume = localStorage.getItem("volume") || "1";
$volumeSlider.value = savedVolume;
// iOS doesn't support programmatic volume control
if (isIOS) {
// Disable the volume slider on iOS and show a message
$volumeSlider.disabled = true;
$volumeSlider.style.opacity = "0.5";
$volumeSlider.style.cursor = "not-allowed";
// Add a tooltip/label explaining iOS limitation
const volumeLabel = $volumeSlider.parentElement;
if (volumeLabel) {
// Change layout to vertical (flex-col) instead of horizontal
volumeLabel.classList.remove("inline-flex", "items-center", "gap-2");
volumeLabel.classList.add("flex", "flex-col", "items-start", "gap-1");
const iosNote = document.createElement("span");
iosNote.className = "text-xs text-slate-500 dark:text-slate-400";
iosNote.textContent = "Nutze die Lautstärketasten deines Geräts";
volumeLabel.appendChild(iosNote);
}
} else {
// Non-iOS: Normal volume control
$volumeSlider.addEventListener("input", () => {
const volume = parseFloat($volumeSlider.value);
if (currentSound) {
currentSound.volume(volume);
}
Howler.volume(volume); // Set global volume
localStorage.setItem("volume", String(volume));
});
// Set initial volume for non-iOS devices
const initialVolume = parseFloat(savedVolume);
Howler.volume(initialVolume);
}
}
// Unlock audio on first user interaction (mobile browsers)
const unlockAudio = () => {
Howler.ctx?.resume();
document.removeEventListener("touchstart", unlockAudio);
document.removeEventListener("touchend", unlockAudio);
document.removeEventListener("click", unlockAudio);
};
document.addEventListener("touchstart", unlockAudio);
document.addEventListener("touchend", unlockAudio);
document.addEventListener("click", unlockAudio);
}
/**
* Apply time synchronization with server
*/
export function applySync(startAt, serverNow) {
if (!startAt || !serverNow || !currentSound) return;
if (state.room?.state?.paused) return;
if (state.isBuffering) return;
const now = Date.now();
const elapsed = (now - startAt) / 1000;
const currentSeek = currentSound.seek() || 0;
const drift = currentSeek - elapsed;
const abs = Math.abs(drift);
// Detect iOS/Safari - be more conservative with sync
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const needsGentleSync = isIOS || isSafari;
// iOS Safari: Higher threshold before doing hard seek (less aggressive)
const hardSeekThreshold = needsGentleSync ? 2.0 : 1.0;
// iOS Safari: Higher threshold before adjusting playback rate
const rateAdjustThreshold = needsGentleSync ? 0.3 : 0.12;
if (abs > hardSeekThreshold) {
// Large drift - hard seek
currentSound.seek(Math.max(0, elapsed));
if (!currentSound.playing()) {
currentSound.play();
}
currentSound.rate(1.0);
} else if (abs > rateAdjustThreshold && !needsGentleSync) {
// Small drift - adjust playback rate (skip this on iOS/Safari to avoid stuttering)
const maxNudge = 0.03;
const sign = drift > 0 ? -1 : 1;
const rate = 1 + sign * Math.min(maxNudge, abs * 0.5);
currentSound.rate(Math.max(0.8, Math.min(1.2, rate)));
} else {
// Very small or no drift - reset to normal rate
if (Math.abs(currentSound.rate() - 1) > 0.001) {
currentSound.rate(1.0);
}
}
}
/**
* Stop audio playback and reset state
*/
export function stopAudioPlayback() {
if (currentSound) {
currentSound.stop();
currentSound.unload();
currentSound = null;
}
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
try {
if ($recordDisc) $recordDisc.classList.remove("spin-record");
} catch {}
try {
if ($progressFill) $progressFill.style.width = "0%";
} catch {}
try {
if ($bufferBadge) $bufferBadge.classList.add("hidden");
} catch {}
}

View File

@@ -0,0 +1,60 @@
const el = (id) => document.getElementById(id);
export const $lobby = el("lobby");
export const $room = el("room");
export const $roomId = el("roomId");
export const $nameDisplay = el("nameDisplay");
export const $status = el("status");
export const $guesser = el("guesser");
export const $timeline = el("timeline");
export const $np = el("nowPlaying");
export const $npTitle = el("npTitle");
export const $npArtist = el("npArtist");
export const $npYear = el("npYear");
export const $readyChk = el("readyChk");
export const $startGame = el("startGame");
export const $revealBanner = el("revealBanner");
export const $placeArea = el("placeArea");
export const $slotSelect = el("slotSelect");
export const $placeBtn = el("placeBtn");
export const $placeWithTokensBtn = el("placeWithTokensBtn");
export const $mediaControls = el("mediaControls");
export const $playBtn = el("playBtn");
export const $pauseBtn = el("pauseBtn");
export const $nextArea = el("nextArea");
export const $nextBtn = el("nextBtn");
export const $recordDisc = el("recordDisc");
export const $progressFill = el("progressFill");
export const $volumeSlider = el("volumeSlider");
export const $bufferBadge = el("bufferBadge");
export const $copyRoomCode = el("copyRoomCode");
export const $nameLobby = el("name");
export const $saveName = el("saveName");
export const $createRoom = el("createRoom");
export const $joinRoom = el("joinRoom");
export const $roomCode = el("roomCode");
export const $leaveRoom = el("leaveRoom");
export const $earnToken = el("earnToken");
export const $dashboardList = el("dashboardList");
export const $toast = el("toast");
// Answer form elements
export const $answerForm = el("answerForm");
export const $guessTitle = el("guessTitle");
export const $guessArtist = el("guessArtist");
export const $answerResult = el("answerResult");
// Playlist elements
export const $playlistSection = el("playlistSection");
export const $playlistSelect = el("playlistSelect");
export const $currentPlaylist = el("currentPlaylist");
export const $playlistInfo = el("playlistInfo");
// SFX controls
export const $sfxVolumeSlider = el("sfxVolumeSlider");
export function showLobby() {
$lobby.classList.remove("hidden");
$room.classList.add("hidden");
}
export function showRoom() {
$lobby.classList.add("hidden");
$room.classList.remove("hidden");
}

View File

@@ -0,0 +1,382 @@
import {
$answerResult,
$guessArtist,
$guessTitle,
$npArtist,
$npTitle,
$npYear,
} from "./dom.js";
import { state } from "./state.js";
import { cacheLastRoomId, cacheSessionId, clearSessionId, sendMsg } from "./ws.js";
import { renderRoom } from "./render.js";
import { applySync, loadTrack, getSound, stopAudioPlayback } from "./audio.js";
import { playCorrect, playWrong, playWinner, playTurnStart, playCoinEarned } from "./sfx.js";
import { showReaction, celebrateCorrect } from "./reactions.js";
function updatePlayerIdFromRoom(r) {
try {
if (r?.players?.length === 1) {
const only = r.players[0];
if (only && only.id && only.id !== state.playerId) {
state.playerId = only.id;
try {
localStorage.setItem("playerId", only.id);
} catch { }
}
}
} catch { }
}
function shortName(id) {
if (!id) return "-";
const p = state.room?.players.find((x) => x.id === id);
return p ? p.name : id.slice(0, 4);
}
export function handleConnected(msg) {
state.playerId = msg.playerId;
try {
if (msg.playerId) localStorage.setItem("playerId", msg.playerId);
} catch { }
if (msg.sessionId) {
const existing = localStorage.getItem("sessionId");
if (!existing) cacheSessionId(msg.sessionId);
}
// lazy import to avoid cycle
import("./session.js").then(({ reusePlayerName, reconnectLastRoom }) => {
reusePlayerName();
reconnectLastRoom();
});
if (state.room) {
try {
updatePlayerIdFromRoom(state.room);
renderRoom(state.room);
} catch { }
}
}
export function handleRoomUpdate(msg) {
if (msg?.room?.id) cacheLastRoomId(msg.room.id);
const r = msg.room;
updatePlayerIdFromRoom(r);
renderRoom(r);
}
export function handlePlayTrack(msg) {
const t = msg.track;
state.lastTrack = t;
state.revealed = false;
$npTitle.textContent = "???";
$npArtist.textContent = "";
$npYear.textContent = "";
if ($guessTitle) $guessTitle.value = "";
if ($guessArtist) $guessArtist.value = "";
if ($answerResult) {
$answerResult.textContent = "";
$answerResult.className = "mt-1 text-sm";
}
// Load track with Howler, passing the filename for format detection
const sound = loadTrack(t.url, t.file);
const pf = document.getElementById("progressFill");
if (pf) pf.style.width = "0%";
const rd = document.getElementById("recordDisc");
if (rd) {
rd.classList.remove("spin-record");
rd.src = "/hitstar.png";
}
const { startAt, serverNow } = msg;
const now = Date.now();
const offsetMs = startAt - serverNow;
const localStart = now + offsetMs;
const delay = Math.max(0, localStart - now);
setTimeout(() => {
if (sound && sound === getSound() && !sound.playing()) {
sound.play();
const disc = document.getElementById("recordDisc");
if (disc) disc.classList.add("spin-record");
}
}, delay);
// Play turn start sound if it's the current player's turn
if (state.room?.state?.currentGuesser === state.playerId) {
playTurnStart();
}
if (state.room) renderRoom(state.room);
}
export function handleSync(msg) {
applySync(msg.startAt, msg.serverNow);
}
export function handleControl(msg) {
const { action, startAt, serverNow } = msg;
const sound = getSound();
if (!sound) return;
if (action === "pause") {
sound.pause();
const disc = document.getElementById("recordDisc");
if (disc) disc.classList.remove("spin-record");
sound.rate(1.0);
} else if (action === "play") {
if (startAt && serverNow) {
const now = Date.now();
const elapsed = (now - startAt) / 1000;
sound.seek(Math.max(0, elapsed));
}
sound.play();
const disc = document.getElementById("recordDisc");
if (disc) disc.classList.add("spin-record");
}
}
export function handleReveal(msg) {
const { result, track } = msg;
$npTitle.textContent = track.title || track.id || "Track";
$npArtist.textContent = track.artist ? ` ${track.artist}` : "";
$npYear.textContent = track.year ? ` (${track.year})` : "";
state.revealed = true;
const $rb = document.getElementById("revealBanner");
if ($rb) {
if (result.correct) {
$rb.textContent = "Richtig!";
$rb.className =
"inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium";
playCorrect();
celebrateCorrect(); // Auto 🎉 animation
} else {
$rb.textContent = "Falsch!";
$rb.className =
"inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium";
playWrong();
}
}
// Note: placeArea visibility is now controlled by renderRoom() based on game phase
const rd = document.getElementById("recordDisc");
if (rd && track?.file) {
// Use track.file instead of track.id to include playlist folder prefix
const coverUrl = `/cover/${encodeURIComponent(track.file)}`;
const coverUrlWithTimestamp = `${coverUrl}?t=${Date.now()}`;
const img = new Image();
img.onload = () => {
rd.src = coverUrlWithTimestamp;
};
img.onerror = () => {
/* keep default logo */
};
img.src = coverUrlWithTimestamp;
}
}
export function handleGameEnded(msg) {
// Play winner fanfare
playWinner();
// Create and show the winner popup with confetti
showWinnerPopup(msg);
}
function showWinnerPopup(msg) {
const winnerName = msg.winnerName || shortName(msg.winner);
const score = msg.score ?? 0;
const timeline = msg.timeline || [];
// Create the popup overlay
const overlay = document.createElement('div');
overlay.id = 'winnerOverlay';
overlay.className = 'fixed inset-0 z-50 flex items-center justify-center p-4';
overlay.style.cssText = 'background: rgba(0,0,0,0.75); backdrop-filter: blur(4px);';
// Create timeline cards HTML
const timelineHtml = timeline.length > 0
? timeline.map(t => `
<div class="flex items-start gap-3 bg-white/10 rounded-lg px-3 py-2 w-full">
<div class="flex-shrink-0 font-bold tabular-nums bg-indigo-600 text-white rounded-md px-2 py-1 min-w-[48px] text-center text-sm">${t.year ?? '?'}</div>
<div class="flex-1 min-w-0 leading-tight text-left overflow-hidden">
<div class="font-semibold text-white text-sm break-words" style="word-break: break-word; hyphens: auto;">${escapeHtmlSimple(t.title || 'Unknown')}</div>
<div class="text-xs text-white/70 break-words" style="word-break: break-word;">${escapeHtmlSimple(t.artist || '')}</div>
</div>
</div>
`).join('')
: '<p class="text-white/60 text-sm">Keine Karten</p>';
overlay.innerHTML = `
<div id="confettiContainer" class="fixed inset-0 pointer-events-none overflow-hidden"></div>
<div class="relative bg-gradient-to-br from-indigo-600 via-purple-600 to-pink-600 rounded-2xl shadow-2xl max-w-md w-full p-6 text-center animate-popup">
<div class="absolute -top-6 left-1/2 -translate-x-1/2 text-6xl animate-bounce-slow">🏆</div>
<h2 class="text-3xl font-bold text-white mt-4 mb-2">Gewinner!</h2>
<p class="text-4xl font-extrabold text-yellow-300 mb-2">${escapeHtmlSimple(winnerName)}</p>
<p class="text-lg text-white/90 mb-4">Score: <span class="font-bold text-2xl">${score}</span> Karten</p>
<div class="bg-black/20 rounded-xl p-3 max-h-60 overflow-y-auto">
<h3 class="text-sm font-semibold text-white/80 mb-2">Zeitleiste</h3>
<div class="flex flex-col gap-2">
${timelineHtml}
</div>
</div>
<div class="mt-6 flex flex-col sm:flex-row gap-3 justify-center">
<button id="rematchBtn" class="px-6 py-3 bg-emerald-500 text-white font-bold rounded-xl hover:bg-emerald-600 transition-colors shadow-lg flex items-center justify-center gap-2">
🔄 Rematch
</button>
<button id="closeWinnerBtn" class="px-6 py-3 bg-white text-indigo-600 font-bold rounded-xl hover:bg-indigo-100 transition-colors shadow-lg">
Schließen
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
// Start confetti animation
createConfetti();
// Rematch button handler
document.getElementById('rematchBtn').addEventListener('click', () => {
stopAudioPlayback(); // Stop the music
sendMsg({ type: 'rematch' });
overlay.remove();
});
// Close button handler
document.getElementById('closeWinnerBtn').addEventListener('click', () => {
overlay.remove();
});
// Close on overlay click (outside popup)
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.remove();
}
});
}
function createConfetti() {
const container = document.getElementById('confettiContainer');
if (!container) return;
const colors = ['#FFD700', '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8'];
const confettiCount = 100;
for (let i = 0; i < confettiCount; i++) {
const confetti = document.createElement('div');
confetti.className = 'confetti-piece';
confetti.style.cssText = `
position: absolute;
width: ${Math.random() * 10 + 5}px;
height: ${Math.random() * 10 + 5}px;
background: ${colors[Math.floor(Math.random() * colors.length)]};
left: ${Math.random() * 100}%;
top: -20px;
border-radius: ${Math.random() > 0.5 ? '50%' : '2px'};
animation: confetti-fall ${Math.random() * 3 + 2}s linear forwards;
animation-delay: ${Math.random() * 0.5}s;
transform: rotate(${Math.random() * 360}deg);
`;
container.appendChild(confetti);
}
// Clean up confetti after animation
setTimeout(() => {
container.innerHTML = '';
}, 4000);
}
function escapeHtmlSimple(s) {
return String(s).replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]
);
}
export function onMessage(ev) {
const msg = JSON.parse(ev.data);
switch (msg.type) {
case "resume_result": {
if (msg.ok) {
if (msg.playerId) {
state.playerId = msg.playerId;
try {
localStorage.setItem("playerId", msg.playerId);
} catch { }
}
const code =
msg.roomId || state.room?.id || localStorage.getItem("lastRoomId");
if (code) sendMsg({ type: "join_room", roomId: code });
if (state.room) {
try {
renderRoom(state.room);
} catch { }
}
// Restore player name after successful resume
import("./session.js").then(({ reusePlayerName }) => {
reusePlayerName();
});
} else {
// Resume failed - session expired or server restarted
// Clear stale session ID so next connect gets a fresh one
clearSessionId();
// Still try to join the last room
const code = state.room?.id || localStorage.getItem("lastRoomId");
if (code) sendMsg({ type: "join_room", roomId: code });
// Restore player name even on failed resume (new player, same name)
import("./session.js").then(({ reusePlayerName }) => {
reusePlayerName();
});
}
return;
}
case "connected":
return handleConnected(msg);
case "room_update":
return handleRoomUpdate(msg);
case "play_track":
return handlePlayTrack(msg);
case "sync":
return handleSync(msg);
case "control":
return handleControl(msg);
case "reveal":
return handleReveal(msg);
case "game_ended":
return handleGameEnded(msg);
case "answer_result": {
if ($answerResult) {
if (!msg.ok) {
$answerResult.textContent =
"⛔ Eingabe ungültig oder gerade nicht möglich";
$answerResult.className = "mt-1 text-sm text-rose-600";
} else {
const okBoth = !!(msg.correctTitle && msg.correctArtist);
const parts = [];
parts.push(msg.correctTitle ? "Titel ✓" : "Titel ✗");
parts.push(msg.correctArtist ? "Künstler ✓" : "Künstler ✗");
let coin = "";
if (msg.awarded) {
coin = " +1 Token";
playCoinEarned();
} else if (msg.alreadyAwarded) {
coin = " (bereits erhalten)";
}
$answerResult.textContent = `${parts.join(" · ")}${coin}`;
$answerResult.className = okBoth
? "mt-1 text-sm text-emerald-600"
: "mt-1 text-sm text-amber-600";
}
}
return;
}
default:
return;
case "reaction": {
// Show reaction from another player
showReaction(msg.emoji, msg.playerName);
return;
}
}
}

View File

@@ -0,0 +1,93 @@
/**
* Live Reactions Module for Hitstar Party Mode
* Real-time emoji reactions visible to all players
*/
import { sendMsg } from './ws.js';
import { state } from './state.js';
// Rate limiting
let lastReactionTime = 0;
const REACTION_COOLDOWN_MS = 500;
// Available reactions
export const REACTIONS = ['😂', '😱', '🔥', '👏', '🎉'];
/**
* Send a reaction to all players
* @param {string} emoji - The emoji to send
*/
export function sendReaction(emoji) {
const now = Date.now();
if (now - lastReactionTime < REACTION_COOLDOWN_MS) {
return; // Rate limited
}
lastReactionTime = now;
sendMsg({ type: 'reaction', emoji });
}
/**
* Display a floating reaction animation
* @param {string} emoji - The emoji to display
* @param {string} playerName - Name of the player who reacted
*/
export function showReaction(emoji, playerName) {
const container = document.getElementById('reactionContainer');
if (!container) return;
// Create reaction element
const reaction = document.createElement('div');
reaction.className = 'reaction-float';
// Random horizontal position (20-80% of container width)
const xPos = 20 + Math.random() * 60;
reaction.style.cssText = `
position: absolute;
bottom: 0;
left: ${xPos}%;
transform: translateX(-50%);
font-size: 2.5rem;
pointer-events: none;
animation: reaction-rise 2s ease-out forwards;
display: flex;
flex-direction: column;
align-items: center;
z-index: 100;
`;
// Show player name if not self
const isMe = state.room?.players?.some(p => p.id === state.playerId && p.name === playerName);
const nameLabel = playerName && !isMe ? `<span style="font-size: 0.7rem; color: white; background: rgba(0,0,0,0.5); padding: 2px 6px; border-radius: 4px; margin-top: 4px; white-space: nowrap;">${escapeHtml(playerName)}</span>` : '';
reaction.innerHTML = `
<span style="text-shadow: 0 2px 8px rgba(0,0,0,0.3);">${emoji}</span>
${nameLabel}
`;
container.appendChild(reaction);
// Remove after animation
setTimeout(() => {
reaction.remove();
}, 2000);
}
/**
* Trigger celebration reactions (auto 🎉)
*/
export function celebrateCorrect() {
// Show multiple party emojis
for (let i = 0; i < 3; i++) {
setTimeout(() => {
showReaction('🎉', '');
}, i * 150);
}
}
function escapeHtml(s) {
return String(s).replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]
);
}

View File

@@ -10,6 +10,7 @@ import {
$nextArea,
$np,
$placeArea,
$placeWithTokensBtn,
$readyChk,
$revealBanner,
$room,
@@ -29,7 +30,7 @@ export function renderRoom(room) {
}
try {
localStorage.setItem('lastRoomId', room.id);
} catch {}
} catch { }
$lobby.classList.add('hidden');
$room.classList.remove('hidden');
$roomId.textContent = room.id;
@@ -43,7 +44,7 @@ export function renderRoom(room) {
state.playerId = sole.id;
try {
localStorage.setItem('playerId', sole.id);
} catch {}
} catch { }
}
}
const me = room.players.find((p) => p.id === state.playerId);
@@ -84,11 +85,11 @@ export function renderRoom(room) {
const year = t.year ?? '?';
const badgeStyle = badgeColorForYear(year);
return `
<div class="flex items-center gap-2 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2 bg-white text-slate-900 dark:bg-slate-800 dark:text-slate-100 shadow-sm" title="${title}${artist ? ' — ' + artist : ''} (${year})">
<div class="font-bold tabular-nums text-white rounded-md px-2 py-0.5 min-w-[3ch] text-center" style="${badgeStyle}">${year}</div>
<div class="leading-tight">
<div class="font-semibold">${title}</div>
<div class="text-sm text-slate-600 dark:text-slate-300">${artist}</div>
<div class="flex items-start gap-3 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2 bg-white text-slate-900 dark:bg-slate-800 dark:text-slate-100 shadow-sm w-full" title="${title}${artist ? ' — ' + artist : ''} (${year})">
<div class="flex-shrink-0 font-bold tabular-nums text-white rounded-md px-2 py-1 min-w-[48px] text-center" style="${badgeStyle}">${year}</div>
<div class="flex-1 min-w-0 leading-tight overflow-hidden">
<div class="font-semibold break-words" style="word-break: break-word; hyphens: auto;">${title}</div>
<div class="text-sm text-slate-600 dark:text-slate-300 break-words" style="word-break: break-word;">${artist}</div>
</div>
</div>
`;
@@ -146,20 +147,29 @@ export function renderRoom(room) {
// Mark that we've populated the dropdown
state.playlistsPopulated = true;
}
// Auto-select first playlist if host and in lobby (only on first population)
if (
!room.state.playlist &&
isHost &&
room.state.status === 'lobby' &&
state.playlists.length > 0
) {
// Use setTimeout to ensure the change event is properly triggered after render
// Auto-select first playlist if host and in lobby but no playlist is set on server
// This handles both initial population AND returning to lobby after a game
if (
!room.state.playlist &&
isHost &&
room.state.status === 'lobby' &&
state.playlists.length > 0
) {
// Use setTimeout to ensure the change event is properly triggered after render
// Use a flag to prevent multiple auto-selects during the same render cycle
if (!state._autoSelectPending) {
state._autoSelectPending = true;
setTimeout(() => {
const firstPlaylistId = state.playlists[0].id;
$playlistSelect.value = firstPlaylistId;
// Trigger the change event to send to server
$playlistSelect.dispatchEvent(new Event('change'));
state._autoSelectPending = false;
// Double-check we're still in the right state
if (state.room?.state?.status === 'lobby' && !state.room?.state?.playlist) {
const firstPlaylistId = state.playlists[0].id;
$playlistSelect.value = firstPlaylistId;
// Trigger the change event to send to server
$playlistSelect.dispatchEvent(new Event('change'));
}
}, 100);
}
}
@@ -169,9 +179,23 @@ export function renderRoom(room) {
$playlistSelect.value = room.state.playlist;
}
}
// Sync goal selector with server state
const $goalSelect = document.getElementById('goalSelect');
if ($goalSelect && room.state.goal) {
$goalSelect.value = String(room.state.goal);
}
if ($currentPlaylist) {
$currentPlaylist.textContent = room.state.playlist || 'default';
}
// Update goal info display
const $goalInfo = document.getElementById('goalInfo');
if ($goalInfo) {
$goalInfo.textContent = `${room.state.goal || 10} Karten`;
}
if ($playlistInfo) {
$playlistInfo.classList.toggle('hidden', room.state.status === 'lobby');
}
@@ -189,6 +213,8 @@ export function renderRoom(room) {
if ($placeArea && $slotSelect) {
if (canGuess) {
const tl = room.state.timeline?.[state.playerId] || [];
// Preserve selected slot across re-renders
const previousValue = $slotSelect.value;
$slotSelect.innerHTML = '';
for (let i = 0; i <= tl.length; i++) {
const left = i > 0 ? (tl[i - 1]?.year ?? '?') : null;
@@ -203,6 +229,21 @@ export function renderRoom(room) {
opt.textContent = label;
$slotSelect.appendChild(opt);
}
// Restore previous selection if still valid
if (previousValue && parseInt(previousValue, 10) <= tl.length) {
$slotSelect.value = previousValue;
}
// Enable/disable the "use tokens" button based on token count
if ($placeWithTokensBtn) {
const myTokens = room.state.tokens?.[state.playerId] ?? 0;
const TOKEN_COST = 3;
const canUseTokens = myTokens >= TOKEN_COST;
$placeWithTokensBtn.disabled = !canUseTokens;
$placeWithTokensBtn.title = canUseTokens
? 'Nutze 3 Tokens um die Karte automatisch richtig zu platzieren'
: `Du brauchst mindestens ${TOKEN_COST} Tokens (aktuell: ${myTokens})`;
}
} else {
// Clear options when not guessing
$slotSelect.innerHTML = '';
@@ -230,6 +271,12 @@ export function renderRoom(room) {
$answerResult.textContent = '';
$answerResult.className = 'mt-1 text-sm';
}
// Show reaction bar during gameplay
const $reactionBar = document.getElementById('reactionBar');
if ($reactionBar) {
$reactionBar.classList.toggle('hidden', room.state.status !== 'playing');
}
}
export function shortName(id) {

View File

@@ -17,7 +17,9 @@ export function reusePlayerName() {
export function reconnectLastRoom() {
const last = state.room?.id || localStorage.getItem('lastRoomId');
if (last && !localStorage.getItem('sessionId')) {
sendMsg({ type: 'join_room', code: last });
// Always try to rejoin the last room - resume is handled separately in ws.js
if (last) {
sendMsg({ type: 'join_room', roomId: last });
}
}

View File

@@ -0,0 +1,157 @@
/**
* Sound Effects Module for Hitstar Party Mode
* Uses Howler.js for audio playback with synthesized tones
*/
// SFX volume (0-1), separate from music
let sfxVolume = 0.7;
// Audio context for generating tones
let audioCtx = null;
function getAudioContext() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
return audioCtx;
}
/**
* Play a synthesized tone
* @param {number} frequency - Frequency in Hz
* @param {number} duration - Duration in seconds
* @param {string} type - Oscillator type: 'sine', 'square', 'sawtooth', 'triangle'
* @param {number} [volume] - Volume multiplier (0-1)
*/
function playTone(frequency, duration, type = 'sine', volume = 1) {
try {
const ctx = getAudioContext();
if (ctx.state === 'suspended') {
ctx.resume();
}
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, ctx.currentTime);
// Apply volume with fade out
const effectiveVolume = sfxVolume * volume * 0.3; // Keep SFX quieter than music
gainNode.gain.setValueAtTime(effectiveVolume, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration);
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + duration);
} catch (e) {
console.warn('SFX playback failed:', e);
}
}
/**
* Play a sequence of tones
* @param {Array<{freq: number, dur: number, delay: number, type?: string, vol?: number}>} notes
*/
function playSequence(notes) {
notes.forEach(note => {
setTimeout(() => {
playTone(note.freq, note.dur, note.type || 'sine', note.vol || 1);
}, note.delay * 1000);
});
}
// ============= Sound Effect Functions =============
/**
* Play success sound - rising two-tone chime
*/
export function playCorrect() {
playSequence([
{ freq: 523.25, dur: 0.15, delay: 0, type: 'sine' }, // C5
{ freq: 659.25, dur: 0.25, delay: 0.1, type: 'sine' }, // E5
]);
}
/**
* Play error sound - descending buzz
*/
export function playWrong() {
playSequence([
{ freq: 220, dur: 0.15, delay: 0, type: 'sawtooth', vol: 0.5 }, // A3
{ freq: 175, dur: 0.25, delay: 0.12, type: 'sawtooth', vol: 0.5 }, // F3
]);
}
/**
* Play winner fanfare
*/
export function playWinner() {
playSequence([
{ freq: 523.25, dur: 0.12, delay: 0, type: 'square', vol: 0.6 }, // C5
{ freq: 659.25, dur: 0.12, delay: 0.12, type: 'square', vol: 0.6 }, // E5
{ freq: 783.99, dur: 0.12, delay: 0.24, type: 'square', vol: 0.6 }, // G5
{ freq: 1046.50, dur: 0.4, delay: 0.36, type: 'sine', vol: 0.8 }, // C6 (victory note)
]);
}
/**
* Play turn start notification ping
*/
export function playTurnStart() {
playSequence([
{ freq: 880, dur: 0.08, delay: 0, type: 'sine', vol: 0.4 }, // A5
{ freq: 1108.73, dur: 0.15, delay: 0.08, type: 'sine', vol: 0.6 }, // C#6
]);
}
/**
* Play coin/token earned sound
*/
export function playCoinEarned() {
playSequence([
{ freq: 1318.51, dur: 0.06, delay: 0, type: 'sine', vol: 0.5 }, // E6
{ freq: 1567.98, dur: 0.06, delay: 0.05, type: 'sine', vol: 0.6 }, // G6
{ freq: 2093, dur: 0.12, delay: 0.1, type: 'sine', vol: 0.7 }, // C7
]);
}
/**
* Play countdown tick sound
*/
export function playTick() {
playTone(800, 0.05, 'sine', 0.3);
}
/**
* Set SFX volume
* @param {number} volume - Volume level (0-1)
*/
export function setSfxVolume(volume) {
sfxVolume = Math.max(0, Math.min(1, volume));
}
/**
* Get current SFX volume
* @returns {number} Current volume (0-1)
*/
export function getSfxVolume() {
return sfxVolume;
}
/**
* Initialize SFX system (call on first user interaction)
*/
export function initSfx() {
// Pre-warm audio context on user interaction
try {
const ctx = getAudioContext();
if (ctx.state === 'suspended') {
ctx.resume();
}
} catch (e) {
console.warn('SFX initialization failed:', e);
}
}

View File

@@ -0,0 +1,294 @@
import {
$answerResult,
$copyRoomCode,
$createRoom,
$guessArtist,
$guessTitle,
$joinRoom,
$lobby,
$nameDisplay,
$nameLobby,
$nextBtn,
$pauseBtn,
$placeBtn,
$placeWithTokensBtn,
$readyChk,
$room,
$roomCode,
$roomId,
$slotSelect,
$startGame,
$leaveRoom,
$playBtn,
$volumeSlider,
$saveName,
$sfxVolumeSlider,
} from "./dom.js";
import { state } from "./state.js";
import { initAudioUI, stopAudioPlayback } from "./audio.js";
import { sendMsg } from "./ws.js";
import { showToast, wire } from "./utils.js";
import { setSfxVolume, initSfx } from "./sfx.js";
import { sendReaction } from "./reactions.js";
export function wireUi() {
initAudioUI();
initSfx(); // Initialize sound effects system
// Wire reaction buttons
document.querySelectorAll('.reaction-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const emoji = btn.getAttribute('data-emoji');
if (emoji) sendReaction(emoji);
});
});
// --- Name autosave helpers
let nameDebounce;
const SAVE_DEBOUNCE_MS = 800;
// Button was removed; no state management needed.
function saveNameIfChanged(raw) {
const name = (raw || "").trim();
if (!name) return;
try {
const prev = localStorage.getItem("playerName") || "";
if (prev === name) return; // no-op
localStorage.setItem("playerName", name);
if ($nameDisplay) $nameDisplay.textContent = name;
sendMsg({ type: "set_name", name });
showToast("Name gespeichert!");
} catch {
// best-effort
}
}
// Manual save button
if ($saveName) {
wire($saveName, "click", () => {
if (nameDebounce) {
clearTimeout(nameDebounce);
nameDebounce = null;
}
const val = ($nameLobby?.value || "").trim();
if (!val) {
showToast("⚠️ Bitte gib einen Namen ein!");
return;
}
const prev = localStorage.getItem("playerName") || "";
if (prev === val) {
showToast("✓ Name bereits gespeichert!");
return;
}
saveNameIfChanged(val);
});
}
// Autosave on input with debounce
if ($nameLobby) {
wire($nameLobby, "input", () => {
if (nameDebounce) clearTimeout(nameDebounce);
const val = ($nameLobby.value || "").trim();
if (!val) return;
nameDebounce = setTimeout(() => {
saveNameIfChanged($nameLobby.value);
nameDebounce = null;
}, SAVE_DEBOUNCE_MS);
});
// Save immediately on blur
wire($nameLobby, "blur", () => {
if (nameDebounce) {
clearTimeout(nameDebounce);
nameDebounce = null;
}
const val = ($nameLobby.value || "").trim();
if (val) saveNameIfChanged(val);
});
// Save on Enter
wire($nameLobby, "keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
if (nameDebounce) {
clearTimeout(nameDebounce);
nameDebounce = null;
}
const val = ($nameLobby.value || "").trim();
if (val) saveNameIfChanged(val);
}
});
}
// Auto-uppercase room code input for better UX
if ($roomCode) {
wire($roomCode, "input", () => {
const pos = $roomCode.selectionStart;
$roomCode.value = $roomCode.value.toUpperCase();
$roomCode.setSelectionRange(pos, pos);
});
}
wire($createRoom, "click", () => sendMsg({ type: "create_room" }));
wire($joinRoom, "click", () => {
const code = $roomCode.value.trim().toUpperCase();
if (code) sendMsg({ type: "join_room", roomId: code });
});
wire($leaveRoom, "click", () => {
sendMsg({ type: "leave_room" });
try {
localStorage.removeItem("playerId");
localStorage.removeItem("sessionId");
localStorage.removeItem("dashboardHintSeen");
localStorage.removeItem("lastRoomId");
} catch { }
stopAudioPlayback();
state.room = null;
if ($nameLobby) {
try {
const storedName = localStorage.getItem("playerName") || "";
$nameLobby.value = storedName;
} catch {
$nameLobby.value = "";
}
}
if ($nameDisplay) $nameDisplay.textContent = "";
if ($readyChk) {
try {
$readyChk.checked = false;
} catch { }
}
$lobby.classList.remove("hidden");
$room.classList.add("hidden");
});
wire($startGame, "click", () => {
// Validate playlist selection before starting
if (!state.room?.state?.playlist) {
showToast("⚠️ Bitte wähle zuerst eine Playlist aus!");
return;
}
sendMsg({ type: "start_game" });
});
wire($readyChk, "change", (e) => {
const val = !!e.target.checked;
state.pendingReady = val;
sendMsg({ type: "ready", ready: val });
});
// Playlist selection
const $playlistSelect = document.getElementById("playlistSelect");
if ($playlistSelect) {
wire($playlistSelect, "change", (e) => {
const playlistId = e.target.value;
sendMsg({ type: "select_playlist", playlist: playlistId });
});
}
// Goal/score selection
const $goalSelect = document.getElementById("goalSelect");
if ($goalSelect) {
wire($goalSelect, "change", (e) => {
const goal = parseInt(e.target.value, 10);
sendMsg({ type: "set_goal", goal });
});
}
wire($placeBtn, "click", () => {
const slot = parseInt($slotSelect.value, 10);
sendMsg({ type: "place_guess", slot });
});
// Use tokens to place card correctly
wire($placeWithTokensBtn, "click", () => {
const slot = parseInt($slotSelect.value, 10);
sendMsg({ type: "place_guess", slot, useTokens: true });
});
wire($playBtn, "click", () => sendMsg({ type: "resume_play" }));
wire($pauseBtn, "click", () => sendMsg({ type: "pause" }));
wire($nextBtn, "click", () => sendMsg({ type: "skip_track" }));
// Volume slider is now handled in audio.js initAudioUI()
// SFX Volume slider
if ($sfxVolumeSlider) {
wire($sfxVolumeSlider, "input", (e) => {
setSfxVolume(parseFloat(e.target.value));
});
}
if ($copyRoomCode) {
$copyRoomCode.style.display = "inline-block";
wire($copyRoomCode, "click", () => {
if (state.room?.id) {
navigator.clipboard.writeText(state.room.id).then(() => {
$copyRoomCode.textContent = "✔️";
showToast("Code kopiert!");
setTimeout(() => {
$copyRoomCode.textContent = "📋";
}, 1200);
});
}
});
}
if ($roomId) {
wire($roomId, "click", () => {
if (state.room?.id) {
navigator.clipboard.writeText(state.room.id).then(() => {
$roomId.title = "Kopiert!";
showToast("Code kopiert!");
setTimeout(() => {
$roomId.title = "Klicken zum Kopieren";
}, 1200);
});
}
});
$roomId.style.cursor = "pointer";
}
const form = document.getElementById("answerForm");
if (form) {
form.addEventListener("submit", (e) => {
e.preventDefault();
const title = ($guessTitle?.value || "").trim();
const artist = ($guessArtist?.value || "").trim();
if (!title || !artist) {
if ($answerResult) {
$answerResult.textContent = "Bitte Titel und Künstler eingeben";
$answerResult.className = "mt-1 text-sm text-amber-600";
}
return;
}
sendMsg({ type: "submit_answer", guess: { title, artist } });
});
}
// Dashboard one-time hint
const dashboard = document.getElementById("dashboard");
const dashboardHint = document.getElementById("dashboardHint");
if (dashboard && dashboardHint) {
try {
const seen = localStorage.getItem("dashboardHintSeen");
if (!seen) {
dashboardHint.classList.remove("hidden");
const hide = () => {
dashboardHint.classList.add("hidden");
try {
localStorage.setItem("dashboardHintSeen", "1");
} catch { }
dashboard.removeEventListener("toggle", hide);
dashboard.removeEventListener("click", hide);
};
dashboard.addEventListener("toggle", hide);
dashboard.addEventListener("click", hide, { once: true });
setTimeout(() => {
if (!localStorage.getItem("dashboardHintSeen")) hide();
}, 6000);
}
} catch { }
}
}

View File

@@ -20,7 +20,7 @@ export function connectWS(onMessage) {
if (sessionId) {
try {
socket.emit('message', { type: 'resume', sessionId });
} catch {}
} catch { }
}
// flush queued
setTimeout(() => {
@@ -47,12 +47,21 @@ export function cacheSessionId(id) {
sessionId = id;
try {
localStorage.setItem('sessionId', id);
} catch {}
} catch { }
}
export function cacheLastRoomId(id) {
if (!id) return;
_lastRoomId = id;
try {
localStorage.setItem('lastRoomId', id);
} catch {}
} catch { }
}
// Clear stale session data (e.g., after server restart)
export function clearSessionId() {
sessionId = null;
try {
localStorage.removeItem('sessionId');
} catch { }
}

View File

@@ -0,0 +1,38 @@
/**
* Application configuration from environment variables
*/
export interface AppConfig {
port: number;
host: string;
dataDir: string;
publicDir: string;
audioDebugNames: boolean;
tokenTtlMs: number;
logLevel: string;
corsOrigin: string;
}
/**
* Load configuration from environment variables
*/
export function loadConfig(): AppConfig {
return {
port: parseInt(Deno.env.get('PORT') || '5173', 10),
host: Deno.env.get('HOST') || '0.0.0.0',
dataDir: Deno.env.get('DATA_DIR') || './data',
publicDir: Deno.env.get('PUBLIC_DIR') || './public',
audioDebugNames: Deno.env.get('AUDIO_DEBUG_NAMES') === '1',
tokenTtlMs: parseInt(Deno.env.get('TOKEN_TTL_MS') || '600000', 10),
logLevel: Deno.env.get('LOG_LEVEL') || 'INFO',
corsOrigin: Deno.env.get('CORS_ORIGIN') || '*',
};
}
/**
* Resolve path relative to project root
*/
export function resolvePath(relativePath: string): string {
const projectRoot = Deno.cwd();
return new URL(relativePath, `file://${projectRoot}/`).pathname;
}

View File

@@ -0,0 +1,68 @@
/**
* Application constants
*/
export const DEFAULT_PORT = 5173;
export const DEFAULT_HOST = '0.0.0.0';
// Audio streaming
export const TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes
export const COVER_CACHE_MAX_ITEMS = 256;
export const COVER_CACHE_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
export const TOKEN_CACHE_MAX_ITEMS = 2048;
// Game defaults
export const DEFAULT_GAME_GOAL = 10;
export const DEFAULT_PLAYLIST = 'default';
export const HELLO_TIMER_MS = 400;
export const SYNC_INTERVAL_MS = 1000;
// File extensions
export const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.m4a', '.ogg', '.opus'];
export const PREFERRED_EXTENSION = '.opus';
// Metadata parsing
export const BATCH_SIZE = 50; // Process files in batches
// Room ID generation
export const ROOM_ID_LENGTH = 6;
// WebSocket events
export const WS_EVENTS = {
// Client -> Server
RESUME: 'resume',
CREATE_ROOM: 'create_room',
JOIN_ROOM: 'join_room',
LEAVE_ROOM: 'leave_room',
SET_NAME: 'set_name',
READY: 'ready',
START_GAME: 'start_game',
GUESS: 'guess',
PAUSE: 'pause',
RESUME_PLAY: 'resume_play',
SKIP_TRACK: 'skip_track',
SET_SPECTATOR: 'set_spectator',
KICK_PLAYER: 'kick_player',
SELECT_PLAYLIST: 'select_playlist',
SET_GOAL: 'set_goal',
// Server -> Client
CONNECTED: 'connected',
RESUME_RESULT: 'resume_result',
ROOM_UPDATE: 'room_update',
PLAY_TRACK: 'play_track',
GUESS_RESULT: 'guess_result',
GAME_ENDED: 'game_ended',
SYNC: 'sync',
ERROR: 'error',
} as const;
// HTTP routes
export const ROUTES = {
API_PLAYLISTS: '/api/playlists',
API_TRACKS: '/api/tracks',
API_RELOAD_YEARS: '/api/reload-years',
AUDIO_TOKEN: '/audio/t/:token',
AUDIO_NAME: '/audio/:name',
COVER: '/cover/:name',
} as const;

View File

@@ -0,0 +1,49 @@
/**
* Custom error types for the application
*/
export class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number = 500,
) {
super(message);
this.name = 'AppError';
}
}
export class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(message, 'NOT_FOUND', 404);
this.name = 'NotFoundError';
}
}
export class ValidationError extends AppError {
constructor(message = 'Validation failed') {
super(message, 'VALIDATION_ERROR', 400);
this.name = 'ValidationError';
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 'UNAUTHORIZED', 401);
this.name = 'UnauthorizedError';
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 'FORBIDDEN', 403);
this.name = 'ForbiddenError';
}
}
export class ConflictError extends AppError {
constructor(message = 'Conflict') {
super(message, 'CONFLICT', 409);
this.name = 'ConflictError';
}
}

View File

@@ -0,0 +1,39 @@
import * as log from '@std/log';
/**
* Logger configuration and utility
*/
// Initialize logger
export function initLogger(level: string = 'INFO'): void {
const logLevel = level.toUpperCase() as keyof typeof log.LogLevels;
log.setup({
handlers: {
console: new log.ConsoleHandler(logLevel, {
formatter: (record) => {
const timestamp = new Date().toISOString();
return `[${timestamp}] ${record.levelName} ${record.msg}`;
},
}),
},
loggers: {
default: {
level: logLevel,
handlers: ['console'],
},
},
});
}
/**
* Get logger instance
*/
export function getLogger(name = 'default'): log.Logger {
return log.getLogger(name);
}
/**
* Logger instance for easy import
*/
export const logger = getLogger();

View File

@@ -0,0 +1,69 @@
/**
* Generate a UUID v4
*/
export function generateUUID(): string {
return crypto.randomUUID();
}
/**
* Generate a short random ID (for room IDs)
*/
export function generateShortId(length = 6): string {
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
let result = '';
const randomValues = new Uint8Array(length);
crypto.getRandomValues(randomValues);
for (let i = 0; i < length; i++) {
result += chars[randomValues[i] % chars.length];
}
return result;
}
/**
* Shuffle an array using Fisher-Yates algorithm
*/
export function shuffle<T>(array: T[]): T[] {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
/**
* Get next element in circular array
*/
export function getNextInArray<T>(array: T[], current: T): T | null {
if (array.length === 0) return null;
const index = array.indexOf(current);
if (index === -1) return array[0];
return array[(index + 1) % array.length];
}
/**
* Delay execution
*/
export function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Safe JSON parse with fallback
*/
export function safeJsonParse<T>(json: string, fallback: T): T {
try {
return JSON.parse(json);
} catch {
return fallback;
}
}
/**
* Clamp a number between min and max
*/
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}

View File

@@ -0,0 +1,121 @@
import { assertEquals } from 'https://deno.land/std@0.224.0/assert/mod.ts';
import { AnswerCheckService } from '../application/AnswerCheckService.ts';
Deno.test('AnswerCheckService - scoreTitle - exact match', () => {
const service = new AnswerCheckService();
const result = service.scoreTitle('Hello World', 'Hello World');
assertEquals(result.match, true);
assertEquals(result.score, 1.0);
});
Deno.test('AnswerCheckService - scoreTitle - case insensitive', () => {
const service = new AnswerCheckService();
const result = service.scoreTitle('hello world', 'Hello World');
assertEquals(result.match, true);
assertEquals(result.score, 1.0);
});
Deno.test('AnswerCheckService - scoreTitle - with diacritics', () => {
const service = new AnswerCheckService();
const result = service.scoreTitle('café', 'cafe');
assertEquals(result.match, true);
});
Deno.test('AnswerCheckService - scoreTitle - removes remaster info', () => {
const service = new AnswerCheckService();
const result = service.scoreTitle(
'Song Title',
'Song Title (2024 Remastered)'
);
assertEquals(result.match, true);
});
Deno.test('AnswerCheckService - scoreArtist - exact match', () => {
const service = new AnswerCheckService();
const result = service.scoreArtist('The Beatles', 'The Beatles');
assertEquals(result.match, true);
assertEquals(result.score, 1.0);
});
Deno.test('AnswerCheckService - scoreArtist - without "The"', () => {
const service = new AnswerCheckService();
const result = service.scoreArtist('Beatles', 'The Beatles');
assertEquals(result.match, true);
});
Deno.test('AnswerCheckService - scoreArtist - featuring', () => {
const service = new AnswerCheckService();
// Should match first artist
const result1 = service.scoreArtist('Artist A', 'Artist A feat. Artist B');
assertEquals(result1.match, true);
// Should match second artist
const result2 = service.scoreArtist('Artist B', 'Artist A feat. Artist B');
assertEquals(result2.match, true);
});
Deno.test('AnswerCheckService - scoreYear - exact match', () => {
const service = new AnswerCheckService();
const result = service.scoreYear(2020, 2020);
assertEquals(result.match, true);
assertEquals(result.score, 1.0);
});
Deno.test('AnswerCheckService - scoreYear - within 1 year', () => {
const service = new AnswerCheckService();
const result1 = service.scoreYear(2020, 2021);
assertEquals(result1.match, true);
assertEquals(result1.score, 0.9);
const result2 = service.scoreYear(2022, 2021);
assertEquals(result2.match, true);
assertEquals(result2.score, 0.9);
});
Deno.test('AnswerCheckService - scoreYear - string input', () => {
const service = new AnswerCheckService();
const result = service.scoreYear('2020', 2020);
assertEquals(result.match, true);
assertEquals(result.score, 1.0);
});
Deno.test('AnswerCheckService - scoreYear - invalid input', () => {
const service = new AnswerCheckService();
const result = service.scoreYear('invalid', 2020);
assertEquals(result.match, false);
assertEquals(result.score, 0);
});
Deno.test('AnswerCheckService - scoreYear - null correct year', () => {
const service = new AnswerCheckService();
const result = service.scoreYear(2020, null);
assertEquals(result.match, false);
assertEquals(result.score, 0);
});
Deno.test('AnswerCheckService - splitArtists', () => {
const service = new AnswerCheckService();
const result1 = service.splitArtists('Artist A feat. Artist B');
assertEquals(result1.length, 2);
assertEquals(result1[0], 'Artist A');
assertEquals(result1[1], 'Artist B');
const result2 = service.splitArtists('Artist A & Artist B');
assertEquals(result2.length, 2);
const result3 = service.splitArtists('Artist A, Artist B, Artist C');
assertEquals(result3.length, 3);
});

View File

@@ -0,0 +1,54 @@
import { assertEquals } from 'https://deno.land/std@0.224.0/assert/mod.ts';
import { shuffle, generateShortId, getNextInArray } from '../shared/utils.ts';
Deno.test('utils - shuffle - returns different order', () => {
const original = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const shuffled = shuffle(original);
// Length should be the same
assertEquals(shuffled.length, original.length);
// Should contain all elements
for (const item of original) {
assertEquals(shuffled.includes(item), true);
}
// Original should not be modified
assertEquals(original, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
});
Deno.test('utils - generateShortId - generates correct length', () => {
const id = generateShortId(6);
assertEquals(id.length, 6);
});
Deno.test('utils - generateShortId - generates uppercase alphanumeric', () => {
const id = generateShortId(10);
assertEquals(/^[0-9A-Z]+$/.test(id), true);
});
Deno.test('utils - generateShortId - generates unique IDs', () => {
const ids = new Set();
for (let i = 0; i < 100; i++) {
ids.add(generateShortId(6));
}
// Should have close to 100 unique IDs (very unlikely to get duplicates)
assertEquals(ids.size > 95, true);
});
Deno.test('utils - getNextInArray - circular navigation', () => {
const arr = ['a', 'b', 'c'];
assertEquals(getNextInArray(arr, 'a'), 'b');
assertEquals(getNextInArray(arr, 'b'), 'c');
assertEquals(getNextInArray(arr, 'c'), 'a'); // Wraps around
});
Deno.test('utils - getNextInArray - empty array', () => {
assertEquals(getNextInArray([], 'a'), null);
});
Deno.test('utils - getNextInArray - element not in array', () => {
const arr = ['a', 'b', 'c'];
assertEquals(getNextInArray(arr, 'x'), 'a'); // Returns first
});

View File

@@ -1,34 +0,0 @@
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Determine project root directory
const ROOT_DIR = path.resolve(__dirname, '..', '..');
export const PORT = process.env.PORT || 5173;
export const DATA_DIR = path.resolve(ROOT_DIR, 'data');
export const PUBLIC_DIR = path.resolve(ROOT_DIR, 'public');
/**
* Get the data directory for a specific playlist
* @param {string} [playlistId='default'] - The playlist ID
* @returns {string} The absolute path to the playlist directory
*/
export function getPlaylistDir(playlistId = 'default') {
return playlistId === 'default' ? DATA_DIR : path.join(DATA_DIR, playlistId);
}
/**
* Get the years.json path for a specific playlist
* @param {string} [playlistId='default'] - The playlist ID
* @returns {string} The absolute path to the years.json file
*/
export function getYearsPath(playlistId = 'default') {
const dir = getPlaylistDir(playlistId);
return path.join(dir, 'years.json');
}
// Legacy export for backward compatibility - points to root data/years.json
export const YEARS_PATH = path.join(DATA_DIR, 'years.json');
export const PATHS = { __dirname, ROOT_DIR, DATA_DIR, PUBLIC_DIR, YEARS_PATH };

View File

@@ -1,457 +0,0 @@
import { Server as SocketIOServer } from 'socket.io';
import { v4 as uuidv4 } from 'uuid';
import { rooms, createRoom, broadcast, roomSummary, nextPlayer, shuffle } from './game/state.js';
import { createAudioToken } from './routes/audio.js';
import { loadDeck } from './game/deck.js';
import { startSyncTimer, stopSyncTimer } from './game/sync.js';
import { scoreTitle, scoreArtist, splitArtists } from './game/answerCheck.js';
function drawNextTrack(room) {
const track = room.deck.shift();
if (!track) {
room.state.status = 'ended';
room.state.winner = null;
broadcast(room, 'game_ended', { winner: null });
return;
}
// Generate an opaque, short-lived token for streaming the audio without exposing the file name
let tokenUrl = null;
try {
const token = createAudioToken(track.file);
tokenUrl = `/audio/t/${token}`;
} catch {
// Fallback to name-based URL if token generation fails (should be rare)
tokenUrl = `/audio/${encodeURIComponent(track.file)}`;
}
room.state.currentTrack = { ...track, url: tokenUrl };
room.state.phase = 'guess';
room.state.lastResult = null;
room.state.paused = false;
room.state.pausedPosSec = 0;
room.state.awardedThisRound = {}; // reset per-round coin awards
room.state.trackStartAt = Date.now() + 800;
broadcast(room, 'play_track', {
track: room.state.currentTrack,
startAt: room.state.trackStartAt,
serverNow: Date.now(),
});
broadcast(room, 'room_update', { room: roomSummary(room) });
startSyncTimer(room);
}
export function setupWebSocket(server) {
const io = new SocketIOServer(server, {
transports: ['websocket'],
cors: { origin: true, methods: ['GET', 'POST'] },
});
io.on('connection', (socket) => {
// Create a tentative player identity, but don't immediately commit or send it.
const newId = uuidv4();
const newSessionId = uuidv4();
let player = {
id: newId,
sessionId: newSessionId,
name: `Player-${newId.slice(0, 4)}`,
ws: socket,
connected: true,
roomId: null,
};
const send = (type, payload) => {
try {
socket.emit('message', { type, ...payload });
} catch {}
};
// To avoid overwriting an existing session on reconnect, delay the initial
// welcome until we see if the client sends a resume message.
let helloSent = false;
const sendHello = (p) => {
if (helloSent) return;
helloSent = true;
send('connected', { playerId: p.id, sessionId: p.sessionId });
};
// Fallback: if no resume arrives shortly, treat as a fresh connection.
const helloTimer = setTimeout(() => {
sendHello(player);
}, 400);
function isParticipant(room, pid) {
if (!room) return false;
if (room.state.turnOrder?.includes(pid)) return true;
if (room.state.timeline && Object.hasOwn(room.state.timeline, pid)) return true;
if (room.state.tokens && Object.hasOwn(room.state.tokens, pid)) return true;
return false;
}
socket.on('message', async (msg) => {
if (!msg || typeof msg !== 'object') return;
// Allow client to resume by session token
if (msg.type === 'resume') {
clearTimeout(helloTimer);
const reqSession = String(msg.sessionId || '');
if (!reqSession) {
send('resume_result', { ok: false, reason: 'no_session' });
return;
}
let found = null;
let foundRoom = null;
for (const room of rooms.values()) {
for (const p of room.players.values()) {
if (p.sessionId === reqSession) {
found = p;
foundRoom = room;
break;
}
}
if (found) break;
}
if (!found) {
send('resume_result', { ok: false, reason: 'not_found' });
return;
}
// Rebind socket and mark connected
try {
if (found.ws?.id && found.ws.id !== socket.id) {
try {
found.ws.disconnect(true);
} catch {}
}
} catch {}
found.ws = socket;
found.connected = true;
player = found; // switch our local reference to the existing player
// If they were a participant, ensure they are not marked spectator
if (foundRoom) {
if (isParticipant(foundRoom, found.id)) {
if (foundRoom.state.spectators) delete foundRoom.state.spectators[found.id];
found.spectator = false;
}
}
// Send resume result and an explicit connected that preserves their original session
// BEFORE broadcasting room_update. This ensures the client sets playerId before
// rendering the first room snapshot.
send('resume_result', { ok: true, playerId: found.id, roomId: foundRoom?.id });
helloSent = true;
send('connected', { playerId: found.id, sessionId: found.sessionId });
if (foundRoom) {
// Now notify the room (and the resumed client) of the updated presence
broadcast(foundRoom, 'room_update', { room: roomSummary(foundRoom) });
}
return;
}
// Automatic answer check (anyone can try during guess phase)
if (msg.type === 'submit_answer') {
const room = rooms.get(player.roomId);
if (!room) return;
const current = room.state.currentTrack;
if (!current) {
send('answer_result', { ok: false, error: 'no_track' });
return;
}
if (room.state.status !== 'playing' || room.state.phase !== 'guess') {
send('answer_result', { ok: false, error: 'not_accepting' });
return;
}
if (room.state.spectators?.[player.id]) {
send('answer_result', { ok: false, error: 'spectator' });
return;
}
const guess = msg.guess || {};
const guessTitle = String(guess.title || '').slice(0, 200);
const guessArtist = String(guess.artist || '').slice(0, 200);
if (!guessTitle || !guessArtist) {
send('answer_result', { ok: false, error: 'invalid' });
return;
}
const titleScore = scoreTitle(guessTitle, current.title || current.id || '');
const artistScore = scoreArtist(guessArtist, splitArtists(current.artist || ''), 1);
const correct = !!(titleScore.pass && artistScore.pass);
let awarded = false;
let alreadyAwarded = false;
if (correct) {
room.state.awardedThisRound = room.state.awardedThisRound || {};
if (room.state.awardedThisRound[player.id]) {
alreadyAwarded = true;
} else {
const currentTokens = room.state.tokens[player.id] ?? 0;
room.state.tokens[player.id] = Math.min(5, currentTokens + 1);
room.state.awardedThisRound[player.id] = true;
awarded = true;
}
}
send('answer_result', {
ok: true,
correctTitle: titleScore.pass,
correctArtist: artistScore.pass,
scoreTitle: { sim: +titleScore.sim.toFixed(3), jaccard: +titleScore.jac.toFixed(3) },
scoreArtist: +artistScore.best.toFixed(3),
normalized: {
guessTitle: titleScore.g,
truthTitle: titleScore.t,
guessArtists: artistScore.guessArtists,
truthArtists: artistScore.truthArtists,
},
awarded,
alreadyAwarded,
});
if (awarded) {
broadcast(room, 'room_update', { room: roomSummary(room) });
}
return;
}
if (msg.type === 'set_name') {
player.name = String(msg.name || '').slice(0, 30) || player.name;
if (player.roomId && rooms.has(player.roomId)) {
const r = rooms.get(player.roomId);
broadcast(r, 'room_update', { room: roomSummary(r) });
}
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' });
// If there's an existing player in this room with the same session, merge to avoid duplicates.
let existing = null;
for (const p of room.players.values()) {
if (p.sessionId && p.sessionId === player.sessionId && p.id !== player.id) {
existing = p;
break;
}
}
if (existing) {
try {
if (existing.ws?.id && existing.ws.id !== socket.id) {
try {
existing.ws.disconnect(true);
} catch {}
}
} catch {}
existing.ws = socket;
existing.connected = true;
existing.roomId = room.id;
player = existing;
}
room.players.set(player.id, player);
player.roomId = room.id;
if (!room.state.ready) room.state.ready = {};
if (room.state.ready[player.id] == null) room.state.ready[player.id] = false;
const inProgress = room.state.status === 'playing' || room.state.status === 'ended';
const wasParticipant = isParticipant(room, player.id);
if (inProgress) {
if (wasParticipant) {
if (room.state.spectators) delete room.state.spectators[player.id];
player.spectator = false;
} else {
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 === 'set_playlist') {
const room = rooms.get(player.roomId);
if (!room) return;
if (room.hostId !== player.id)
return send('error', { message: 'Only host can change playlist' });
if (room.state.status !== 'lobby')
return send('error', { message: 'Can only change playlist in lobby' });
const playlistId = String(msg.playlist || 'default');
room.state.playlist = playlistId;
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' });
if (!room.state.playlist)
return send('error', { message: 'Please select a playlist first' });
const active = [...room.players.values()].filter(
(p) => !room.state.spectators?.[p.id] && p.connected
);
const allReady = active.length > 0 && active.every((p) => !!room.state.ready?.[p.id]);
if (!allReady) return send('error', { message: 'All active players must be ready' });
const pids = active.map((p) => p.id);
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]));
// Load deck with the selected playlist
const playlistId = room.state.playlist || 'default';
room.deck = await loadDeck(playlistId);
room.discard = [];
room.state.phase = 'guess';
room.state.lastResult = null;
drawNextTrack(room);
return;
}
if (msg.type === 'player_control') {
const room = rooms.get(player.roomId);
if (!room) return;
const { action } = msg;
if (room.state.status !== 'playing') return;
if (room.state.phase !== 'guess') return;
if (room.state.currentGuesser !== player.id) return;
if (!room.state.currentTrack) 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' });
}
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 === 'place_guess') {
const room = rooms.get(player.roomId);
if (!room) return;
const { position, slot: rawSlot } = msg;
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' });
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;
let correct = false;
if (current.year != null) {
if (n === 0) correct = slot === 0;
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) {
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);
}
room.state.phase = 'reveal';
room.state.lastResult = { playerId: player.id, correct };
broadcast(room, 'reveal', {
result: room.state.lastResult,
track: room.state.currentTrack,
});
broadcast(room, 'room_update', { room: roomSummary(room) });
const tlNow = room.state.timeline[player.id] || [];
if (correct && tlNow.length >= room.state.goal) {
room.state.status = 'ended';
room.state.winner = player.id;
broadcast(room, 'game_ended', { winner: player.id });
return;
}
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;
const isAuthorized = player.id === room.hostId || player.id === room.state.currentGuesser;
if (!isAuthorized) return;
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);
}
});
socket.on('disconnect', () => {
clearTimeout(helloTimer);
// Mark player disconnected but keep them in the room for resume
try {
if (player) {
player.connected = false;
if (player.roomId && rooms.has(player.roomId)) {
const room = rooms.get(player.roomId);
broadcast(room, 'room_update', { room: roomSummary(room) });
}
}
} catch {}
});
});
}

View File

@@ -1,171 +0,0 @@
// Fuzzy matching helpers for title/artist guessing
function stripDiacritics(s) {
return String(s)
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '');
}
function normalizeCommon(s) {
return stripDiacritics(String(s))
.toLowerCase()
.replace(/\s*(?:&|and|x|×|with|vs\.?|feat\.?|featuring)\s*/g, ' ')
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function cleanTitleNoise(raw) {
let s = String(raw);
s = s
.replace(/\(([^)]*remaster[^)]*)\)/gi, '')
.replace(/\(([^)]*radio edit[^)]*)\)/gi, '')
.replace(/\(([^)]*edit[^)]*)\)/gi, '')
.replace(/\(([^)]*version[^)]*)\)/gi, '')
.replace(/\(([^)]*live[^)]*)\)/gi, '')
.replace(/\(([^)]*mono[^)]*|[^)]*stereo[^)]*)\)/gi, '');
s = s.replace(
/\b(remaster(?:ed)?(?: \d{2,4})?|radio edit|single version|original mix|version|live)\b/gi,
''
);
return s;
}
function normalizeTitle(s) {
return normalizeCommon(cleanTitleNoise(s));
}
function normalizeArtist(s) {
return normalizeCommon(s)
.replace(/\bthe\b/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// Produce a variant with anything inside parentheses (...) or double quotes "..." removed.
function stripOptionalSegments(raw) {
let s = String(raw);
// Remove double-quoted segments first
s = s.replace(/"[^"]*"/g, ' ');
// Remove parenthetical segments (non-nested)
s = s.replace(/\([^)]*\)/g, ' ');
// Remove square-bracket segments [ ... ] (non-nested)
s = s.replace(/\[[^\]]*\]/g, ' ');
return s;
}
function normalizeTitleBaseOptional(s) {
// Clean general noise, then drop optional quoted/parenthetical parts, then normalize
return normalizeCommon(stripOptionalSegments(cleanTitleNoise(s)));
}
function tokenize(s) {
return s ? String(s).split(' ').filter(Boolean) : [];
}
function tokenSet(s) {
return new Set(tokenize(s));
}
function jaccard(a, b) {
const A = tokenSet(a),
B = tokenSet(b);
if (A.size === 0 && B.size === 0) return 1;
let inter = 0;
for (const t of A) if (B.has(t)) inter++;
const union = A.size + B.size - inter;
return union ? inter / union : 0;
}
function levenshtein(a, b) {
a = String(a);
b = String(b);
const m = a.length,
n = b.length;
if (!m) return n;
if (!n) return m;
const dp = new Array(n + 1);
for (let j = 0; j <= n; j++) dp[j] = j;
for (let i = 1; i <= m; i++) {
let prev = dp[0];
dp[0] = i;
for (let j = 1; j <= n; j++) {
const temp = dp[j];
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost);
prev = temp;
}
}
return dp[n];
}
function simRatio(a, b) {
if (!a && !b) return 1;
if (!a || !b) return 0;
const d = levenshtein(a, b);
return 1 - d / Math.max(String(a).length, String(b).length);
}
export function splitArtists(raw) {
const unified = String(raw)
.replace(/\s*(?:,|&|and|x|×|with|vs\.?|feat\.?|featuring)\s*/gi, ',')
.replace(/,+/g, ',')
.replace(/(^,|,$)/g, '');
const parts = unified.split(',').map(normalizeArtist).filter(Boolean);
return Array.from(new Set(parts));
}
const TITLE_SIM_THRESHOLD = 0.86;
const TITLE_JACCARD_THRESHOLD = 0.8;
const ARTIST_SIM_THRESHOLD = 0.82;
export function scoreTitle(guessRaw, truthRaw) {
// Full normalized (keeps parentheses/quotes content after punctuation cleanup)
const gFull = normalizeTitle(guessRaw);
const tFull = normalizeTitle(truthRaw);
// Base normalized (treat anything in () or "" as optional and remove it)
const gBase = normalizeTitleBaseOptional(guessRaw);
const tBase = normalizeTitleBaseOptional(truthRaw);
const pairs = [
[gFull, tFull],
[gFull, tBase],
[gBase, tFull],
[gBase, tBase],
];
let bestSim = 0;
let bestJac = 0;
let pass = false;
let bestPair = pairs[0];
for (const [g, t] of pairs) {
const sim = simRatio(g, t);
const jac = jaccard(g, t);
if (sim >= TITLE_SIM_THRESHOLD || jac >= TITLE_JACCARD_THRESHOLD) pass = true;
if (sim > bestSim || (sim === bestSim && jac > bestJac)) {
bestSim = sim;
bestJac = jac;
bestPair = [g, t];
}
}
return { pass, sim: bestSim, jac: bestJac, g: bestPair[0], t: bestPair[1] };
}
export function scoreArtist(guessRaw, truthArtistsRaw, primaryCount) {
const truthArtists = (truthArtistsRaw || []).map((a) => normalizeArtist(a));
const truthSet = new Set(truthArtists);
const guessArtists = splitArtists(guessRaw);
const matches = new Set();
for (const ga of guessArtists) {
for (const ta of truthSet) {
const s = simRatio(ga, ta);
if (s >= ARTIST_SIM_THRESHOLD) matches.add(ta);
}
}
const primary = truthArtists.slice(0, primaryCount || truthArtists.length);
const pass = primary.some((p) => matches.has(p)); // accept any one artist
let best = 0;
for (const ga of guessArtists) {
for (const ta of truthSet) best = Math.max(best, simRatio(ga, ta));
}
return { pass, best, matched: Array.from(matches), guessArtists, truthArtists };
}
export default { scoreTitle, scoreArtist, splitArtists };

View File

@@ -1,116 +0,0 @@
import fs from 'fs';
import path from 'path';
import { parseFile as mmParseFile } from 'music-metadata';
import { DATA_DIR } from '../config.js';
import { loadYearsIndex } from '../years.js';
import { shuffle } from './state.js';
/**
* Get list of available playlists (subdirectories in data folder).
* Also includes a special "default" playlist if there are audio files in the root data directory.
* @returns {Array<{id: string, name: string, trackCount: number}>}
*/
export function getAvailablePlaylists() {
try {
const entries = fs.readdirSync(DATA_DIR, { withFileTypes: true });
const playlists = [];
// Check for files in root directory (legacy/default playlist)
const rootFiles = entries.filter(
(e) => e.isFile() && /\.(mp3|wav|m4a|ogg|opus)$/i.test(e.name)
);
if (rootFiles.length > 0) {
playlists.push({
id: 'default',
name: 'Default (Root Folder)',
trackCount: rootFiles.length,
});
}
// Check subdirectories for playlists
const subdirs = entries.filter((e) => e.isDirectory());
for (const dir of subdirs) {
const dirPath = path.join(DATA_DIR, dir.name);
const dirFiles = fs.readdirSync(dirPath).filter((f) => /\.(mp3|wav|m4a|ogg|opus)$/i.test(f));
if (dirFiles.length > 0) {
playlists.push({
id: dir.name,
name: dir.name,
trackCount: dirFiles.length,
});
}
}
return playlists;
} catch (error) {
console.error('Error reading playlists:', error);
return [];
}
}
/**
* Load a deck of tracks from a specific playlist.
* @param {string} [playlistId='default'] - The playlist ID to load from
* @returns {Promise<Array>} Shuffled array of track objects
*/
export async function loadDeck(playlistId = 'default') {
// Load the years index for this specific playlist
const years = loadYearsIndex(playlistId);
// Determine the directory to load from
const targetDir = playlistId === 'default' ? DATA_DIR : path.join(DATA_DIR, playlistId);
// Validate that the directory exists
if (!fs.existsSync(targetDir)) {
console.error(`Playlist directory not found: ${targetDir}`);
return [];
}
const files = fs.readdirSync(targetDir).filter((f) => /\.(mp3|wav|m4a|ogg|opus)$/i.test(f));
if (files.length === 0) {
console.warn(`No audio files found in playlist: ${playlistId}`);
return [];
}
// Process files in batches to avoid "too many open files" error
const BATCH_SIZE = 50; // Process 50 files at a time
const tracks = [];
for (let i = 0; i < files.length; i += BATCH_SIZE) {
const batch = files.slice(i, i + BATCH_SIZE);
const batchTracks = await Promise.all(
batch.map(async (f) => {
const fp = path.join(targetDir, f);
// For file paths, we need to include the playlist subdirectory if not default
const relativeFile = playlistId === 'default' ? f : path.join(playlistId, f);
// Get metadata from JSON first (priority), then from audio file as fallback
const jsonMeta = years[f];
let year = jsonMeta?.year ?? null;
let title = jsonMeta?.title ?? path.parse(f).name;
let artist = jsonMeta?.artist ?? '';
// Only parse audio file if JSON doesn't have title or artist metadata
// Note: year is ONLY taken from years.json, never from audio file
if (!jsonMeta || !jsonMeta.title || !jsonMeta.artist) {
try {
const meta = await mmParseFile(fp, { duration: false });
title = jsonMeta?.title || meta.common.title || title;
artist = jsonMeta?.artist || meta.common.artist || artist;
// year remains from years.json only - do not use meta.common.year
} catch (err) {
// Log error but continue processing
console.warn(`Failed to parse metadata for ${f}:`, err.message);
}
}
return { id: f, file: relativeFile, title, artist, year };
})
);
tracks.push(...batchTracks);
}
console.log(`Loaded ${tracks.length} tracks from playlist: ${playlistId}`);
return shuffle(tracks);
}

View File

@@ -1,74 +0,0 @@
export const rooms = new Map();
export 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;
}
export 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];
}
export 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',
turnOrder: [],
currentGuesser: null,
currentTrack: null,
timeline: {},
tokens: {},
ready: { [host.id]: false },
spectators: {},
phase: 'guess',
lastResult: null,
trackStartAt: null,
paused: false,
pausedPosSec: 0,
goal: 10,
playlist: null, // Selected playlist for this room (must be chosen by host)
},
};
rooms.set(id, room);
return room;
}
export function broadcast(room, type, payload) {
for (const p of room.players.values()) {
try {
p.ws?.emit?.('message', { type, ...payload });
} catch {}
}
}
export 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,
};
}

View File

@@ -1,22 +0,0 @@
import { broadcast } from './state.js';
export 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);
}
export function stopSyncTimer(room) {
if (room.syncTimer) {
clearInterval(room.syncTimer);
room.syncTimer = null;
}
}

View File

@@ -1,43 +0,0 @@
import express from 'express';
import http from 'http';
import fs from 'fs';
import { PORT, DATA_DIR, PUBLIC_DIR } from './config.js';
import { registerAudioRoutes } from './routes/audio.js';
import { registerTracksApi } from './tracks.js';
import { loadYearsIndex } from './years.js';
import { setupWebSocket } from './game.js';
import { getAvailablePlaylists } from './game/deck.js';
// Ensure data dir exists
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
const app = express();
// Static client
app.use(express.static(PUBLIC_DIR));
// Years reload endpoint (supports optional playlist parameter)
app.get('/api/reload-years', (req, res) => {
const playlistId = req.query.playlist || 'default';
const years = loadYearsIndex(playlistId);
res.json({ ok: true, count: Object.keys(years).length, playlist: playlistId });
});
// Playlists endpoint
app.get('/api/playlists', (req, res) => {
const playlists = getAvailablePlaylists();
res.json({ ok: true, playlists });
});
// Routes
registerAudioRoutes(app);
registerTracksApi(app);
const server = http.createServer(app);
setupWebSocket(server);
server.listen(PORT, () => {
console.log(`Hitstar server running on http://localhost:${PORT}`);
});

View File

@@ -1,147 +0,0 @@
import path from 'path';
import { TOKEN_TTL_MS_DEFAULT, putToken, getToken } from './audio/tokenStore.js';
import { resolveSafePath, fileExists, statFile, getMimeType } from './audio/pathUtils.js';
import { headFile, streamFile } from './audio/streaming.js';
import { getCoverForFile } from './audio/coverService.js';
export function createAudioToken(name, ttlMs = TOKEN_TTL_MS_DEFAULT) {
let resolved = resolveSafePath(name);
if (!resolved) throw new Error('Invalid path');
if (!fileExists(resolved)) throw new Error('Not found');
// Prefer .opus sibling for streaming, to save bandwidth
const ext = path.extname(resolved).toLowerCase();
if (ext !== '.opus') {
const opusCandidate = resolved.slice(0, -ext.length) + '.opus';
if (fileExists(opusCandidate)) {
resolved = opusCandidate;
}
}
const stat = statFile(resolved);
const type = getMimeType(resolved, 'audio/mpeg');
return putToken({ path: resolved, mime: type, size: stat.size }, ttlMs);
}
export function registerAudioRoutes(app) {
const ENABLE_NAME_ENDPOINT = process.env.AUDIO_DEBUG_NAMES === '1';
// HEAD by token
app.head('/audio/t/:token', (req, res) => {
const token = String(req.params.token || '');
const info = getToken(token);
if (!info) return res.status(404).end();
return headFile(res, { size: info.size, mime: info.mime || 'audio/mpeg' });
});
// GET by token (Range support)
app.get('/audio/t/:token', (req, res) => {
const token = String(req.params.token || '');
const info = getToken(token);
if (!info) return res.status(404).send('Not found');
try {
return streamFile(req, res, { filePath: info.path, mime: info.mime || 'audio/mpeg' });
} catch (error) {
console.error('Stream error (token):', error);
return res.status(500).send('Stream error');
}
});
if (ENABLE_NAME_ENDPOINT) {
app.head('/audio/:name', (req, res) => {
const filePath = resolveSafePath(req.params.name);
if (!filePath) return res.status(400).end();
if (!fileExists(filePath)) return res.status(404).end();
const { size } = statFile(filePath);
const type = getMimeType(filePath, 'audio/mpeg');
return headFile(res, { size, mime: type });
});
app.get('/audio/:name', (req, res) => {
const filePath = resolveSafePath(req.params.name);
if (!filePath) return res.status(400).send('Invalid path');
if (!fileExists(filePath)) return res.status(404).send('Not found');
const type = getMimeType(filePath, 'audio/mpeg');
try {
return streamFile(req, res, { filePath, mime: type });
} catch (error) {
console.error('Stream error (name):', error);
return res.status(500).send('Stream error');
}
});
} else {
// Explicitly block the name-based endpoint to avoid leaking song names
app.all('/audio/:name', (req, res) => res.status(404).send('Not found'));
}
// Serve embedded cover art from audio files, if present
app.get('/cover/:name(*)', async (req, res) => {
try {
const requestedName = req.params.name;
let resolved = resolveSafePath(requestedName);
if (!resolved) return res.status(400).send('Invalid path');
let exists = fileExists(resolved);
// If the requested file doesn't exist, try alternative strategies
if (!exists) {
const extensions = ['.opus', '.mp3', '.m4a', '.wav', '.ogg'];
const baseName = requestedName.replace(/\.[^.]+$/i, ''); // Remove extension
const originalExt = path.extname(requestedName).toLowerCase();
let foundFile = null;
// Strategy 1: Try alternative extensions in the same location
for (const ext of extensions) {
if (ext === originalExt) continue;
const altName = baseName + ext;
const altResolved = resolveSafePath(altName);
if (altResolved && fileExists(altResolved)) {
foundFile = altResolved;
break;
}
}
// Strategy 2: If not found and path doesn't contain subdirectory, search in playlist folders
if (!foundFile && !requestedName.includes('/') && !requestedName.includes('\\')) {
const { DATA_DIR } = await import('../config.js');
const fs = await import('fs');
try {
// Get all subdirectories (playlists)
const entries = fs.readdirSync(DATA_DIR, { withFileTypes: true });
const subdirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
// Try to find the file in each playlist folder with all extensions
for (const subdir of subdirs) {
for (const ext of extensions) {
const fileName = path.basename(baseName) + ext;
const playlistPath = path.join(subdir, fileName);
const playlistResolved = resolveSafePath(playlistPath);
if (playlistResolved && fileExists(playlistResolved)) {
foundFile = playlistResolved;
break;
}
}
if (foundFile) break;
}
} catch (searchErr) {
console.debug('Error searching playlists:', searchErr.message);
}
}
if (!foundFile) {
return res.status(404).send('Not found');
}
resolved = foundFile;
}
const cover = await getCoverForFile(resolved);
if (!cover) return res.status(404).send('No cover');
res.setHeader('Content-Type', cover.mime || 'image/jpeg');
res.setHeader('Cache-Control', 'no-store');
return res.status(200).end(cover.buf);
} catch (error) {
console.error('Error reading cover:', error);
return res.status(500).send('Error reading cover');
}
});
}

View File

@@ -1,27 +0,0 @@
import { LRUCache } from 'lru-cache';
import { parseFile as mmParseFile } from 'music-metadata';
const COVER_CACHE_MAX_ITEMS = 256;
const COVER_CACHE_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
const coverCache = new LRUCache({
max: COVER_CACHE_MAX_ITEMS,
maxSize: COVER_CACHE_MAX_BYTES,
sizeCalculation: (v) => (v?.buf?.length ? v.buf.length : 0),
ttl: 5 * 60 * 1000,
ttlAutopurge: true,
});
export async function getCoverForFile(resolvedPath) {
const cached = coverCache.get(resolvedPath);
if (cached) return cached;
const meta = await mmParseFile(resolvedPath, { duration: false });
const pic = meta.common?.picture?.[0];
if (!pic?.data?.length) return null;
const mime = pic.format || 'image/jpeg';
const buf = Buffer.from(pic.data);
const value = { mime, buf };
coverCache.set(resolvedPath, value);
return value;
}

View File

@@ -1,37 +0,0 @@
import fs from 'fs';
import path from 'path';
import mime from 'mime';
import { DATA_DIR as RAW_DATA_DIR } from '../../config.js';
// Resolve DATA_DIR once, ensure absolute path and normalized with trailing separator for prefix checks
const DATA_DIR = path.resolve(RAW_DATA_DIR);
const DATA_DIR_WITH_SEP = DATA_DIR.endsWith(path.sep) ? DATA_DIR : DATA_DIR + path.sep;
export function resolveSafePath(name) {
if (!name || typeof name !== 'string') return null;
// Prevent path traversal and normalize input
const joined = path.join(DATA_DIR, name);
const resolved = path.resolve(joined);
if (resolved === DATA_DIR || resolved.startsWith(DATA_DIR_WITH_SEP)) return resolved;
return null;
}
export function fileExists(p) {
try {
// Ensure the path is safe before checking existence
return fs.existsSync(p);
} catch {
return false;
}
}
export function statFile(p) {
return fs.statSync(p);
}
export function getMimeType(p, fallback = 'audio/mpeg') {
const t = mime.getType(p) || fallback;
// Some clients expect audio/ogg for .opus in Ogg container
if (/\.opus$/i.test(p)) return 'audio/ogg';
return t;
}

View File

@@ -1,46 +0,0 @@
import fs from 'fs';
export function setCommonNoCacheHeaders(res) {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Accept-Ranges', 'bytes');
}
export function headFile(res, { size, mime }) {
setCommonNoCacheHeaders(res);
res.setHeader('Content-Type', mime);
res.setHeader('Content-Length', size);
res.status(200).end();
}
export function streamFile(req, res, { filePath, mime }) {
const stat = fs.statSync(filePath);
const size = stat.size;
const range = req.headers.range;
setCommonNoCacheHeaders(res);
if (range) {
const match = /bytes=(\d+)-(\d+)?/.exec(range);
let start = match?.[1] ? parseInt(match[1], 10) : 0;
let end = match?.[2] ? parseInt(match[2], 10) : size - 1;
if (Number.isNaN(start)) start = 0;
if (Number.isNaN(end)) end = size - 1;
start = Math.min(Math.max(0, start), Math.max(0, size - 1));
end = Math.min(Math.max(start, end), Math.max(0, size - 1));
if (start > end || start >= size) {
res.setHeader('Content-Range', `bytes */${size}`);
return res.status(416).end();
}
const chunkSize = end - start + 1;
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${size}`,
'Content-Length': chunkSize,
'Content-Type': mime,
});
fs.createReadStream(filePath, { start, end }).pipe(res);
} else {
res.writeHead(200, {
'Content-Length': size,
'Content-Type': mime,
});
fs.createReadStream(filePath).pipe(res);
}
}

View File

@@ -1,31 +0,0 @@
import crypto from 'crypto';
import { LRUCache } from 'lru-cache';
const TOKEN_TTL_MS_DEFAULT = 10 * 60 * 1000; // 10 minutes
// A bounded LRU with TTL per entry. Values: { path, mime, size }
const tokenCache = new LRUCache({
max: 2048,
ttl: TOKEN_TTL_MS_DEFAULT,
ttlAutopurge: true,
allowStale: false,
updateAgeOnGet: false,
updateAgeOnHas: false,
});
export function genToken() {
return crypto.randomBytes(16).toString('hex');
}
export function putToken(value, ttlMs = TOKEN_TTL_MS_DEFAULT) {
const token = genToken();
tokenCache.set(token, value, { ttl: Math.max(1000, ttlMs) });
return token;
}
export function getToken(token) {
if (!token) return null;
return tokenCache.get(String(token));
}
export { TOKEN_TTL_MS_DEFAULT };

View File

@@ -1,68 +0,0 @@
import fs from 'fs';
import path from 'path';
import { parseFile as mmParseFile } from 'music-metadata';
import { getPlaylistDir } from './config.js';
import { loadYearsIndex } from './years.js';
/**
* List tracks from a specific playlist
* @param {string} [playlistId='default'] - The playlist ID to list tracks from
* @returns {Promise<Array>} Array of track objects
*/
export async function listTracks(playlistId = 'default') {
const years = loadYearsIndex(playlistId);
const targetDir = getPlaylistDir(playlistId);
const files = fs
.readdirSync(targetDir)
.filter((f) => /\.(mp3|wav|m4a|ogg|opus)$/i.test(f))
.filter((f) => {
// If both base.ext and base.opus exist, list only once (prefer .opus)
const ext = path.extname(f).toLowerCase();
if (ext === '.opus') return true;
const opusTwin = f.replace(/\.[^.]+$/i, '.opus');
return !fs.existsSync(path.join(targetDir, opusTwin));
});
const tracks = await Promise.all(
files.map(async (f) => {
// Prefer .opus for playback if exists
const ext = path.extname(f).toLowerCase();
const opusName = ext === '.opus' ? f : f.replace(/\.[^.]+$/i, '.opus');
const chosen = fs.existsSync(path.join(targetDir, opusName)) ? opusName : f;
const fp = path.join(targetDir, chosen);
// Get metadata from JSON first (priority), then from audio file as fallback
const jsonMeta = years[f] || years[chosen];
let year = jsonMeta?.year ?? null;
let title = jsonMeta?.title ?? path.parse(f).name;
let artist = jsonMeta?.artist ?? '';
// Only parse audio file if JSON doesn't have title or artist metadata
// Note: year is ONLY taken from years.json, never from audio file
if (!jsonMeta || !jsonMeta.title || !jsonMeta.artist) {
try {
const meta = await mmParseFile(fp, { duration: false });
title = jsonMeta?.title || meta.common.title || title;
artist = jsonMeta?.artist || meta.common.artist || artist;
// year remains from years.json only - do not use meta.common.year
} catch {}
}
return { id: chosen, file: chosen, title, artist, year };
})
);
return tracks;
}
export function registerTracksApi(app) {
app.get('/api/tracks', async (req, res) => {
try {
// Support optional playlist parameter (defaults to 'default')
const playlistId = req.query.playlist || 'default';
const tracks = await listTracks(playlistId);
res.json({ tracks, playlist: playlistId });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
}

View File

@@ -1,32 +0,0 @@
import fs from 'fs';
import { YEARS_PATH, getYearsPath } from './config.js';
/**
* Load years index for a specific playlist
* @param {string} [playlistId='default'] - The playlist ID to load years for
* @returns {Object} The years index (byFile mapping)
*/
export function loadYearsIndex(playlistId = 'default') {
const yearsPath = getYearsPath(playlistId);
try {
const raw = fs.readFileSync(yearsPath, 'utf8');
const j = JSON.parse(raw);
if (j && j.byFile && typeof j.byFile === 'object') return j.byFile;
} catch {
console.debug(`No years.json found for playlist '${playlistId}' at ${yearsPath}`);
}
return {};
}
/**
* Legacy function for backward compatibility - loads from root
* @deprecated Use loadYearsIndex(playlistId) instead
*/
export function loadYearsIndexLegacy() {
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 {};
}

File diff suppressed because it is too large Load Diff