feat: added playlist option
All checks were successful
Build and Push Docker Image / docker (push) Successful in 10s

This commit is contained in:
2025-10-12 15:32:53 +02:00
parent 3f52382cdc
commit 17faca1f46
22 changed files with 28651 additions and 10272 deletions

View File

@@ -3,10 +3,32 @@ 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

@@ -282,6 +282,18 @@ export function setupWebSocket(server) {
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;
@@ -297,7 +309,9 @@ export function setupWebSocket(server) {
room.state.currentGuesser = room.state.turnOrder[0];
room.state.timeline = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, []]));
room.state.tokens = Object.fromEntries(room.state.turnOrder.map((pid) => [pid, 2]));
room.deck = await loadDeck();
// 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;

View File

@@ -5,24 +5,118 @@ import { DATA_DIR } from '../config.js';
import { loadYearsIndex } from '../years.js';
import { shuffle } from './state.js';
export async function loadDeck() {
const years = loadYearsIndex();
const files = fs.readdirSync(DATA_DIR).filter((f) => /\.(mp3|wav|m4a|ogg)$/i.test(f));
const tracks = await Promise.all(
files.map(async (f) => {
const fp = path.join(DATA_DIR, f);
let year = null,
title = path.parse(f).name,
artist = '';
try {
const meta = await mmParseFile(fp, { duration: false });
title = meta.common.title || title;
artist = meta.common.artist || artist;
year = meta.common.year || null;
} catch {}
const y = years[f]?.year ?? year;
return { id: f, file: f, title, artist, year: y };
})
);
/**
* 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 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);
}

View File

@@ -42,6 +42,7 @@ export function createRoom(name, host) {
paused: false,
pausedPosSec: 0,
goal: 10,
playlist: 'default', // Selected playlist for this room
},
};
rooms.set(id, room);

View File

@@ -6,6 +6,7 @@ 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)) {
@@ -17,10 +18,17 @@ const app = express();
// Static client
app.use(express.static(PUBLIC_DIR));
// Years reload endpoint
// Years reload endpoint (supports optional playlist parameter)
app.get('/api/reload-years', (req, res) => {
const years = loadYearsIndex();
res.json({ ok: true, count: Object.keys(years).length });
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

View File

@@ -75,11 +75,65 @@ export function registerAudioRoutes(app) {
}
// Serve embedded cover art from audio files, if present
app.get('/cover/:name', async (req, res) => {
app.get('/cover/:name(*)', async (req, res) => {
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 (!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);
if (!cover) return res.status(404).send('No cover');
res.setHeader('Content-Type', cover.mime || 'image/jpeg');

View File

@@ -18,6 +18,7 @@ export function resolveSafePath(name) {
export function fileExists(p) {
try {
// Ensure the path is safe before checking existence
return fs.existsSync(p);
} catch {
return false;

View File

@@ -1,39 +1,53 @@
import fs from 'fs';
import path from 'path';
import { parseFile as mmParseFile } from 'music-metadata';
import { DATA_DIR } from './config.js';
import { getPlaylistDir } from './config.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
.readdirSync(DATA_DIR)
.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(DATA_DIR, opusTwin));
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(DATA_DIR, opusName)) ? opusName : f;
const fp = path.join(DATA_DIR, chosen);
let year = null;
let title = path.parse(f).name;
let artist = '';
try {
const meta = await mmParseFile(fp, { duration: false });
title = meta.common.title || title;
artist = meta.common.artist || artist;
year = meta.common.year || null;
} catch {}
const y = years[f]?.year ?? years[chosen]?.year ?? year;
return { id: chosen, file: chosen, title, artist, year: y };
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 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 {}
}
return { id: chosen, file: chosen, title, artist, year };
})
);
return tracks;
@@ -42,8 +56,10 @@ export async function listTracks() {
export function registerTracksApi(app) {
app.get('/api/tracks', async (req, res) => {
try {
const tracks = await listTracks();
res.json({ tracks });
// 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,7 +1,28 @@
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 {
const raw = fs.readFileSync(YEARS_PATH, 'utf8');
const j = JSON.parse(raw);