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