feat: added playlist option
All checks were successful
Build and Push Docker Image / docker (push) Successful in 10s
All checks were successful
Build and Push Docker Image / docker (push) Successful in 10s
This commit is contained in:
34
README.md
34
README.md
@@ -65,8 +65,38 @@ Dann im Browser öffnen: http://localhost:5173
|
|||||||
## Ordnerstruktur
|
## Ordnerstruktur
|
||||||
|
|
||||||
- `public/` – Client (HTML/CSS/JS)
|
- `public/` – Client (HTML/CSS/JS)
|
||||||
- `server.js` – Express + WebSocket Server, Game-State
|
- `src/server/` – Express + WebSocket Server, Game-State
|
||||||
- `data/` – eure MP3-Dateien
|
- `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:**
|
||||||
|
|
||||||
|
```
|
||||||
|
data/
|
||||||
|
├── (Audio-Dateien hier = "Default" Playlist)
|
||||||
|
├── 80s-Hits/
|
||||||
|
│ ├── Song1.opus
|
||||||
|
│ ├── Song2.opus
|
||||||
|
│ └── ...
|
||||||
|
├── Rock-Classics/
|
||||||
|
│ ├── Song1.opus
|
||||||
|
│ └── ...
|
||||||
|
└── Party-Mix/
|
||||||
|
├── Song1.opus
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**So funktioniert's:**
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
## Git & Audio-Dateien
|
## Git & Audio-Dateien
|
||||||
|
|
||||||
|
|||||||
3767
data/.mb_cache.json
3767
data/.mb_cache.json
File diff suppressed because it is too large
Load Diff
BIN
data/hitster_default/temp_cover_art.jpg
Normal file
BIN
data/hitster_default/temp_cover_art.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 288 KiB |
2162
data/hitster_default/years.json
Normal file
2162
data/hitster_default/years.json
Normal file
File diff suppressed because it is too large
Load Diff
26095
data/old_german_chart_hits/years.json
Normal file
26095
data/old_german_chart_hits/years.json
Normal file
File diff suppressed because it is too large
Load Diff
6453
data/years.json
6453
data/years.json
File diff suppressed because it is too large
Load Diff
@@ -122,6 +122,21 @@
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-start">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="text-slate-700 dark:text-slate-300">
|
<div class="text-slate-700 dark:text-slate-300">
|
||||||
@@ -130,6 +145,9 @@
|
|||||||
<div class="text-slate-700 dark:text-slate-300">
|
<div class="text-slate-700 dark:text-slate-300">
|
||||||
Am Zug: <span id="guesser" class="font-medium"></span>
|
Am Zug: <span id="guesser" class="font-medium"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="playlistInfo" class="text-slate-700 dark:text-slate-300">
|
||||||
|
Playlist: <span id="currentPlaylist" class="font-medium">default</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-3 justify-start md:justify-end">
|
<div class="flex flex-wrap items-center gap-3 justify-start md:justify-end">
|
||||||
<label class="inline-flex items-center gap-3 text-slate-700 dark:text-slate-300 select-none">
|
<label class="inline-flex items-center gap-3 text-slate-700 dark:text-slate-300 select-none">
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ export const $answerForm = el('answerForm');
|
|||||||
export const $guessTitle = el('guessTitle');
|
export const $guessTitle = el('guessTitle');
|
||||||
export const $guessArtist = el('guessArtist');
|
export const $guessArtist = el('guessArtist');
|
||||||
export const $answerResult = el('answerResult');
|
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() {
|
export function showLobby() {
|
||||||
$lobby.classList.remove('hidden');
|
$lobby.classList.remove('hidden');
|
||||||
|
|||||||
@@ -145,8 +145,9 @@ export function handleReveal(msg) {
|
|||||||
const $placeArea = document.getElementById('placeArea');
|
const $placeArea = document.getElementById('placeArea');
|
||||||
if ($placeArea) $placeArea.classList.add('hidden');
|
if ($placeArea) $placeArea.classList.add('hidden');
|
||||||
const rd = document.getElementById('recordDisc');
|
const rd = document.getElementById('recordDisc');
|
||||||
if (rd && track?.id) {
|
if (rd && track?.file) {
|
||||||
const coverUrl = `/cover/${encodeURIComponent(track.id)}`;
|
// Use track.file instead of track.id to include playlist folder prefix
|
||||||
|
const coverUrl = `/cover/${encodeURIComponent(track.file)}`;
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
rd.src = coverUrl;
|
rd.src = coverUrl;
|
||||||
|
|||||||
@@ -4,10 +4,28 @@ import { onMessage } from './handlers.js';
|
|||||||
import { wireUi } from './ui.js';
|
import { wireUi } from './ui.js';
|
||||||
import { $nameLobby } from './dom.js';
|
import { $nameLobby } from './dom.js';
|
||||||
|
|
||||||
|
// Fetch and store available playlists
|
||||||
|
async function loadPlaylists() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/playlists');
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.ok && data.playlists) {
|
||||||
|
state.playlists = data.playlists;
|
||||||
|
return data.playlists;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load playlists:', error);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize UI and open WebSocket connection
|
// Initialize UI and open WebSocket connection
|
||||||
wireUi();
|
wireUi();
|
||||||
connectWS(onMessage);
|
connectWS(onMessage);
|
||||||
|
|
||||||
|
// Load playlists on startup
|
||||||
|
loadPlaylists();
|
||||||
|
|
||||||
// Restore name/id immediately for initial render smoothness
|
// Restore name/id immediately for initial render smoothness
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -112,6 +112,35 @@ export function renderRoom(room) {
|
|||||||
|
|
||||||
if ($startGame) $startGame.classList.toggle('hidden', !canStart);
|
if ($startGame) $startGame.classList.toggle('hidden', !canStart);
|
||||||
|
|
||||||
|
// Update playlist display
|
||||||
|
const $playlistSection = document.getElementById('playlistSection');
|
||||||
|
const $playlistSelect = document.getElementById('playlistSelect');
|
||||||
|
const $currentPlaylist = document.getElementById('currentPlaylist');
|
||||||
|
const $playlistInfo = document.getElementById('playlistInfo');
|
||||||
|
if ($playlistSection) {
|
||||||
|
$playlistSection.classList.toggle('hidden', room.state.status !== 'lobby' || !isHost);
|
||||||
|
}
|
||||||
|
if ($playlistSelect && state.playlists && state.playlists.length > 0) {
|
||||||
|
// Populate playlist dropdown if not already populated
|
||||||
|
if ($playlistSelect.options.length === 1 && $playlistSelect.options[0].value === 'default') {
|
||||||
|
$playlistSelect.innerHTML = '';
|
||||||
|
state.playlists.forEach((playlist) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = playlist.id;
|
||||||
|
option.textContent = `${playlist.name} (${playlist.trackCount} Tracks)`;
|
||||||
|
$playlistSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Set selected playlist
|
||||||
|
$playlistSelect.value = room.state.playlist || 'default';
|
||||||
|
}
|
||||||
|
if ($currentPlaylist) {
|
||||||
|
$currentPlaylist.textContent = room.state.playlist || 'default';
|
||||||
|
}
|
||||||
|
if ($playlistInfo) {
|
||||||
|
$playlistInfo.classList.toggle('hidden', room.state.status === 'lobby');
|
||||||
|
}
|
||||||
|
|
||||||
const isMyTurn =
|
const isMyTurn =
|
||||||
room.state.status === 'playing' &&
|
room.state.status === 'playing' &&
|
||||||
room.state.phase === 'guess' &&
|
room.state.phase === 'guess' &&
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ export const state = {
|
|||||||
revealed: false,
|
revealed: false,
|
||||||
pendingReady: null,
|
pendingReady: null,
|
||||||
isBuffering: false,
|
isBuffering: false,
|
||||||
|
playlists: [], // Available playlists
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -129,6 +129,15 @@ export function wireUi() {
|
|||||||
sendMsg({ type: 'set_ready', ready: 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', () => {
|
wire($placeBtn, 'click', () => {
|
||||||
const slot = parseInt($slotSelect.value, 10);
|
const slot = parseInt($slotSelect.value, 10);
|
||||||
sendMsg({ type: 'place_guess', slot });
|
sendMsg({ type: 'place_guess', slot });
|
||||||
|
|||||||
@@ -3,10 +3,32 @@ import { fileURLToPath } from 'url';
|
|||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
// Determine project root directory
|
||||||
const ROOT_DIR = path.resolve(__dirname, '..', '..');
|
const ROOT_DIR = path.resolve(__dirname, '..', '..');
|
||||||
|
|
||||||
export const PORT = process.env.PORT || 5173;
|
export const PORT = process.env.PORT || 5173;
|
||||||
export const DATA_DIR = path.resolve(ROOT_DIR, 'data');
|
export const DATA_DIR = path.resolve(ROOT_DIR, 'data');
|
||||||
export const PUBLIC_DIR = path.resolve(ROOT_DIR, 'public');
|
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 YEARS_PATH = path.join(DATA_DIR, 'years.json');
|
||||||
export const PATHS = { __dirname, ROOT_DIR, DATA_DIR, PUBLIC_DIR, YEARS_PATH };
|
export const PATHS = { __dirname, ROOT_DIR, DATA_DIR, PUBLIC_DIR, YEARS_PATH };
|
||||||
|
|||||||
@@ -282,6 +282,18 @@ export function setupWebSocket(server) {
|
|||||||
broadcast(room, 'room_update', { room: roomSummary(room) });
|
broadcast(room, 'room_update', { room: roomSummary(room) });
|
||||||
return;
|
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') {
|
if (msg.type === 'start_game') {
|
||||||
const room = rooms.get(player.roomId);
|
const room = rooms.get(player.roomId);
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
@@ -297,7 +309,9 @@ export function setupWebSocket(server) {
|
|||||||
room.state.currentGuesser = room.state.turnOrder[0];
|
room.state.currentGuesser = room.state.turnOrder[0];
|
||||||
room.state.timeline = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, []]));
|
room.state.timeline = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, []]));
|
||||||
room.state.tokens = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, 2]));
|
room.state.tokens = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, 2]));
|
||||||
room.deck = await loadDeck();
|
// Load deck with the selected playlist
|
||||||
|
const playlistId = room.state.playlist || 'default';
|
||||||
|
room.deck = await loadDeck(playlistId);
|
||||||
room.discard = [];
|
room.discard = [];
|
||||||
room.state.phase = 'guess';
|
room.state.phase = 'guess';
|
||||||
room.state.lastResult = null;
|
room.state.lastResult = null;
|
||||||
|
|||||||
@@ -5,24 +5,118 @@ import { DATA_DIR } from '../config.js';
|
|||||||
import { loadYearsIndex } from '../years.js';
|
import { loadYearsIndex } from '../years.js';
|
||||||
import { shuffle } from './state.js';
|
import { shuffle } from './state.js';
|
||||||
|
|
||||||
export async function loadDeck() {
|
/**
|
||||||
const years = loadYearsIndex();
|
* Get list of available playlists (subdirectories in data folder).
|
||||||
const files = fs.readdirSync(DATA_DIR).filter((f) => /\.(mp3|wav|m4a|ogg)$/i.test(f));
|
* Also includes a special "default" playlist if there are audio files in the root data directory.
|
||||||
const tracks = await Promise.all(
|
* @returns {Array<{id: string, name: string, trackCount: number}>}
|
||||||
files.map(async (f) => {
|
*/
|
||||||
const fp = path.join(DATA_DIR, f);
|
export function getAvailablePlaylists() {
|
||||||
let year = null,
|
try {
|
||||||
title = path.parse(f).name,
|
const entries = fs.readdirSync(DATA_DIR, { withFileTypes: true });
|
||||||
artist = '';
|
const playlists = [];
|
||||||
try {
|
|
||||||
const meta = await mmParseFile(fp, { duration: false });
|
// Check for files in root directory (legacy/default playlist)
|
||||||
title = meta.common.title || title;
|
const rootFiles = entries.filter(
|
||||||
artist = meta.common.artist || artist;
|
(e) => e.isFile() && /\.(mp3|wav|m4a|ogg|opus)$/i.test(e.name)
|
||||||
year = meta.common.year || null;
|
);
|
||||||
} catch {}
|
if (rootFiles.length > 0) {
|
||||||
const y = years[f]?.year ?? year;
|
playlists.push({
|
||||||
return { id: f, file: f, title, artist, year: y };
|
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 complete metadata
|
||||||
|
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 = jsonMeta?.year ?? meta.common.year ?? 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);
|
||||||
|
|
||||||
|
// Optional: Log progress for large playlists
|
||||||
|
if (files.length > 100 && (i + BATCH_SIZE) % 100 === 0) {
|
||||||
|
console.log(
|
||||||
|
`Loading playlist ${playlistId}: ${Math.min(i + BATCH_SIZE, files.length)}/${files.length} tracks processed`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Loaded ${tracks.length} tracks from playlist: ${playlistId}`);
|
||||||
return shuffle(tracks);
|
return shuffle(tracks);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function createRoom(name, host) {
|
|||||||
paused: false,
|
paused: false,
|
||||||
pausedPosSec: 0,
|
pausedPosSec: 0,
|
||||||
goal: 10,
|
goal: 10,
|
||||||
|
playlist: 'default', // Selected playlist for this room
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
rooms.set(id, room);
|
rooms.set(id, room);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { registerAudioRoutes } from './routes/audio.js';
|
|||||||
import { registerTracksApi } from './tracks.js';
|
import { registerTracksApi } from './tracks.js';
|
||||||
import { loadYearsIndex } from './years.js';
|
import { loadYearsIndex } from './years.js';
|
||||||
import { setupWebSocket } from './game.js';
|
import { setupWebSocket } from './game.js';
|
||||||
|
import { getAvailablePlaylists } from './game/deck.js';
|
||||||
|
|
||||||
// Ensure data dir exists
|
// Ensure data dir exists
|
||||||
if (!fs.existsSync(DATA_DIR)) {
|
if (!fs.existsSync(DATA_DIR)) {
|
||||||
@@ -17,10 +18,17 @@ const app = express();
|
|||||||
// Static client
|
// Static client
|
||||||
app.use(express.static(PUBLIC_DIR));
|
app.use(express.static(PUBLIC_DIR));
|
||||||
|
|
||||||
// Years reload endpoint
|
// Years reload endpoint (supports optional playlist parameter)
|
||||||
app.get('/api/reload-years', (req, res) => {
|
app.get('/api/reload-years', (req, res) => {
|
||||||
const years = loadYearsIndex();
|
const playlistId = req.query.playlist || 'default';
|
||||||
res.json({ ok: true, count: Object.keys(years).length });
|
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
|
// Routes
|
||||||
|
|||||||
@@ -75,11 +75,65 @@ export function registerAudioRoutes(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Serve embedded cover art from audio files, if present
|
// Serve embedded cover art from audio files, if present
|
||||||
app.get('/cover/:name', async (req, res) => {
|
app.get('/cover/:name(*)', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const resolved = resolveSafePath(req.params.name);
|
const requestedName = req.params.name;
|
||||||
|
let resolved = resolveSafePath(requestedName);
|
||||||
if (!resolved) return res.status(400).send('Invalid path');
|
if (!resolved) return res.status(400).send('Invalid path');
|
||||||
if (!fileExists(resolved)) return res.status(404).send('Not found');
|
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);
|
const cover = await getCoverForFile(resolved);
|
||||||
if (!cover) return res.status(404).send('No cover');
|
if (!cover) return res.status(404).send('No cover');
|
||||||
res.setHeader('Content-Type', cover.mime || 'image/jpeg');
|
res.setHeader('Content-Type', cover.mime || 'image/jpeg');
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function resolveSafePath(name) {
|
|||||||
|
|
||||||
export function fileExists(p) {
|
export function fileExists(p) {
|
||||||
try {
|
try {
|
||||||
|
// Ensure the path is safe before checking existence
|
||||||
return fs.existsSync(p);
|
return fs.existsSync(p);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,39 +1,53 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { parseFile as mmParseFile } from 'music-metadata';
|
import { parseFile as mmParseFile } from 'music-metadata';
|
||||||
import { DATA_DIR } from './config.js';
|
import { getPlaylistDir } from './config.js';
|
||||||
import { loadYearsIndex } from './years.js';
|
import { loadYearsIndex } from './years.js';
|
||||||
|
|
||||||
export async function listTracks() {
|
/**
|
||||||
const years = loadYearsIndex();
|
* 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
|
const files = fs
|
||||||
.readdirSync(DATA_DIR)
|
.readdirSync(targetDir)
|
||||||
.filter((f) => /\.(mp3|wav|m4a|ogg|opus)$/i.test(f))
|
.filter((f) => /\.(mp3|wav|m4a|ogg|opus)$/i.test(f))
|
||||||
.filter((f) => {
|
.filter((f) => {
|
||||||
// If both base.ext and base.opus exist, list only once (prefer .opus)
|
// If both base.ext and base.opus exist, list only once (prefer .opus)
|
||||||
const ext = path.extname(f).toLowerCase();
|
const ext = path.extname(f).toLowerCase();
|
||||||
if (ext === '.opus') return true;
|
if (ext === '.opus') return true;
|
||||||
const opusTwin = f.replace(/\.[^.]+$/i, '.opus');
|
const opusTwin = f.replace(/\.[^.]+$/i, '.opus');
|
||||||
return !fs.existsSync(path.join(DATA_DIR, opusTwin));
|
return !fs.existsSync(path.join(targetDir, opusTwin));
|
||||||
});
|
});
|
||||||
const tracks = await Promise.all(
|
const tracks = await Promise.all(
|
||||||
files.map(async (f) => {
|
files.map(async (f) => {
|
||||||
// Prefer .opus for playback if exists
|
// Prefer .opus for playback if exists
|
||||||
const ext = path.extname(f).toLowerCase();
|
const ext = path.extname(f).toLowerCase();
|
||||||
const opusName = ext === '.opus' ? f : f.replace(/\.[^.]+$/i, '.opus');
|
const opusName = ext === '.opus' ? f : f.replace(/\.[^.]+$/i, '.opus');
|
||||||
const chosen = fs.existsSync(path.join(DATA_DIR, opusName)) ? opusName : f;
|
const chosen = fs.existsSync(path.join(targetDir, opusName)) ? opusName : f;
|
||||||
const fp = path.join(DATA_DIR, chosen);
|
const fp = path.join(targetDir, chosen);
|
||||||
let year = null;
|
|
||||||
let title = path.parse(f).name;
|
// Get metadata from JSON first (priority), then from audio file as fallback
|
||||||
let artist = '';
|
const jsonMeta = years[f] || years[chosen];
|
||||||
try {
|
let year = jsonMeta?.year ?? null;
|
||||||
const meta = await mmParseFile(fp, { duration: false });
|
let title = jsonMeta?.title ?? path.parse(f).name;
|
||||||
title = meta.common.title || title;
|
let artist = jsonMeta?.artist ?? '';
|
||||||
artist = meta.common.artist || artist;
|
|
||||||
year = meta.common.year || null;
|
// Only parse audio file if JSON doesn't have complete metadata
|
||||||
} catch {}
|
if (!jsonMeta || !jsonMeta.title || !jsonMeta.artist) {
|
||||||
const y = years[f]?.year ?? years[chosen]?.year ?? year;
|
try {
|
||||||
return { id: chosen, file: chosen, title, artist, year: y };
|
const meta = await mmParseFile(fp, { duration: false });
|
||||||
|
title = jsonMeta?.title || meta.common.title || title;
|
||||||
|
artist = jsonMeta?.artist || meta.common.artist || artist;
|
||||||
|
year = jsonMeta?.year ?? meta.common.year ?? year;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: chosen, file: chosen, title, artist, year };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return tracks;
|
return tracks;
|
||||||
@@ -42,8 +56,10 @@ export async function listTracks() {
|
|||||||
export function registerTracksApi(app) {
|
export function registerTracksApi(app) {
|
||||||
app.get('/api/tracks', async (req, res) => {
|
app.get('/api/tracks', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const tracks = await listTracks();
|
// Support optional playlist parameter (defaults to 'default')
|
||||||
res.json({ tracks });
|
const playlistId = req.query.playlist || 'default';
|
||||||
|
const tracks = await listTracks(playlistId);
|
||||||
|
res.json({ tracks, playlist: playlistId });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({ error: e.message });
|
res.status(500).json({ error: e.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { YEARS_PATH } from './config.js';
|
import { YEARS_PATH, getYearsPath } from './config.js';
|
||||||
|
|
||||||
export function loadYearsIndex() {
|
/**
|
||||||
|
* 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 {
|
try {
|
||||||
const raw = fs.readFileSync(YEARS_PATH, 'utf8');
|
const raw = fs.readFileSync(YEARS_PATH, 'utf8');
|
||||||
const j = JSON.parse(raw);
|
const j = JSON.parse(raw);
|
||||||
|
|||||||
Reference in New Issue
Block a user