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

@@ -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

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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">

View File

@@ -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');

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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' &&

View File

@@ -11,4 +11,5 @@ export const state = {
revealed: false, revealed: false,
pendingReady: null, pendingReady: null,
isBuffering: false, isBuffering: false,
playlists: [], // Available playlists
}; };

View File

@@ -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 });

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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);
} }

View File

@@ -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);

View File

@@ -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

View File

@@ -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');

View File

@@ -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;

View File

@@ -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 });
} }

View File

@@ -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);