237 lines
6.9 KiB
JavaScript
237 lines
6.9 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execSync } = require('child_process');
|
|
|
|
// Cache for validated files to avoid re-validation on every request
|
|
let validationCache = null;
|
|
let cacheForPath = null;
|
|
|
|
// Global shared playlist cache
|
|
let sharedPlaylistFile = null;
|
|
let sharedPlaylistTrackCount = 0;
|
|
let sharedPlaylistPath = null;
|
|
|
|
/**
|
|
* Validate an audio file using ffprobe
|
|
* @param {string} filePath - Path to audio file
|
|
* @returns {boolean} True if file is valid and playable
|
|
*/
|
|
function validateAudioFile(filePath) {
|
|
try {
|
|
// Use ffprobe to check if file is valid and get audio info
|
|
const output = execSync(
|
|
`ffprobe -v error -select_streams a:0 -show_entries stream=codec_name,sample_rate,channels -of json "${filePath}"`,
|
|
{ encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
);
|
|
|
|
const data = JSON.parse(output);
|
|
|
|
// Check if audio stream exists and has valid properties
|
|
if (!data.streams || data.streams.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const stream = data.streams[0];
|
|
|
|
// Validate codec
|
|
if (!stream.codec_name) {
|
|
return false;
|
|
}
|
|
|
|
// Validate sample rate (should be reasonable)
|
|
const sampleRate = parseInt(stream.sample_rate);
|
|
if (!sampleRate || sampleRate < 8000 || sampleRate > 192000) {
|
|
return false;
|
|
}
|
|
|
|
// Validate channels
|
|
const channels = parseInt(stream.channels);
|
|
if (!channels || channels < 1 || channels > 8) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a random music file from the music directory
|
|
* @param {string} musicPath - Path to music directory
|
|
* @returns {string|null} Path to random music file or null
|
|
*/
|
|
function getRandomMusicFile(musicPath) {
|
|
try {
|
|
if (!fs.existsSync(musicPath)) {
|
|
return null;
|
|
}
|
|
const files = fs.readdirSync(musicPath).filter(f => f.endsWith('.ogg'));
|
|
if (files.length === 0) {
|
|
return null;
|
|
}
|
|
const randomFile = files[Math.floor(Math.random() * files.length)];
|
|
return path.join(musicPath, randomFile);
|
|
} catch (error) {
|
|
console.error('Error getting music file:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all valid music files for playlist generation (with caching)
|
|
* @param {string} musicPath - Path to music directory
|
|
* @returns {string[]} Array of validated music file paths
|
|
*/
|
|
function getAllMusicFiles(musicPath) {
|
|
try {
|
|
if (!fs.existsSync(musicPath)) {
|
|
return [];
|
|
}
|
|
|
|
// Return cached results if available for this path
|
|
if (validationCache && cacheForPath === musicPath) {
|
|
return validationCache;
|
|
}
|
|
|
|
const files = fs.readdirSync(musicPath).filter(f => f.endsWith('.ogg'));
|
|
const filePaths = files.map(f => path.join(musicPath, f));
|
|
|
|
console.log(`Validating ${filePaths.length} audio files...`);
|
|
|
|
// Validate each file
|
|
const validFiles = filePaths.filter(validateAudioFile);
|
|
|
|
if (validFiles.length < filePaths.length) {
|
|
console.log(`Warning: ${filePaths.length - validFiles.length} files skipped due to validation errors`);
|
|
}
|
|
|
|
// Cache the results
|
|
validationCache = validFiles;
|
|
cacheForPath = musicPath;
|
|
|
|
return validFiles;
|
|
} catch (error) {
|
|
console.error('Error getting music files:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shuffle array in place using Fisher-Yates algorithm
|
|
* @param {Array} array - Array to shuffle
|
|
* @returns {Array} Shuffled array (same reference)
|
|
*/
|
|
function shuffleArray(array) {
|
|
for (let i = array.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[array[i], array[j]] = [array[j], array[i]];
|
|
}
|
|
return array;
|
|
}
|
|
|
|
/**
|
|
* Initialize shared playlist at startup
|
|
* @param {string} musicPath - Path to music directory
|
|
*/
|
|
function initializeSharedPlaylist(musicPath) {
|
|
const allMusicFiles = getAllMusicFiles(musicPath);
|
|
|
|
if (allMusicFiles.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Create a persistent shared playlist file
|
|
const playlistFile = path.join('/tmp', `shared-playlist-${Date.now()}.txt`);
|
|
|
|
// Build playlist content - repeat enough times for ~24 hours of playback
|
|
const repetitions = Math.max(20, Math.ceil(480 / allMusicFiles.length));
|
|
const playlistLines = [];
|
|
|
|
playlistLines.push('ffconcat version 1.0');
|
|
|
|
for (let i = 0; i < repetitions; i++) {
|
|
const shuffled = shuffleArray([...allMusicFiles]);
|
|
shuffled.forEach(f => {
|
|
const escapedPath = f.replace(/'/g, "'\\''");
|
|
playlistLines.push(`file '${escapedPath}'`);
|
|
playlistLines.push('# Auto-generated entry');
|
|
});
|
|
}
|
|
|
|
fs.writeFileSync(playlistFile, playlistLines.join('\n'));
|
|
const totalTracks = allMusicFiles.length * repetitions;
|
|
|
|
// Cache the shared playlist
|
|
sharedPlaylistFile = playlistFile;
|
|
sharedPlaylistTrackCount = totalTracks;
|
|
sharedPlaylistPath = musicPath;
|
|
|
|
console.log(`Shared playlist created: ${totalTracks} tracks`);
|
|
}
|
|
|
|
/**
|
|
* Create a rotated playlist starting at a random position
|
|
* @param {string} musicPath - Path to music directory
|
|
* @returns {{playlistFile: string, trackCount: number}|null} Playlist info or null
|
|
*/
|
|
function createPlaylist(musicPath) {
|
|
// Check if we have a shared playlist
|
|
if (!sharedPlaylistFile || !fs.existsSync(sharedPlaylistFile)) {
|
|
console.warn('Warning: Shared playlist not found, initializing...');
|
|
initializeSharedPlaylist(musicPath);
|
|
if (!sharedPlaylistFile) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const allMusicFiles = getAllMusicFiles(musicPath);
|
|
if (allMusicFiles.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Create a rotated copy of the playlist with random start
|
|
const playlistFile = path.join('/tmp', `playlist-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.txt`);
|
|
const randomStart = Math.floor(Math.random() * allMusicFiles.length);
|
|
|
|
const repetitions = Math.max(20, Math.ceil(480 / allMusicFiles.length));
|
|
const playlistLines = [];
|
|
|
|
playlistLines.push('ffconcat version 1.0');
|
|
|
|
// First repetition with rotation
|
|
const firstShuffle = shuffleArray([...allMusicFiles]);
|
|
for (let i = 0; i < firstShuffle.length; i++) {
|
|
const idx = (randomStart + i) % firstShuffle.length;
|
|
const escapedPath = firstShuffle[idx].replace(/'/g, "'\\''");
|
|
playlistLines.push(`file '${escapedPath}'`);
|
|
playlistLines.push('# Auto-generated entry');
|
|
}
|
|
|
|
// Remaining repetitions
|
|
for (let i = 1; i < repetitions; i++) {
|
|
const shuffled = shuffleArray([...allMusicFiles]);
|
|
shuffled.forEach(f => {
|
|
const escapedPath = f.replace(/'/g, "'\\''");
|
|
playlistLines.push(`file '${escapedPath}'`);
|
|
playlistLines.push('# Auto-generated entry');
|
|
});
|
|
}
|
|
|
|
fs.writeFileSync(playlistFile, playlistLines.join('\n'));
|
|
console.log(`Stream playlist created, starting at position ${randomStart}/${allMusicFiles.length}`);
|
|
|
|
return {
|
|
playlistFile,
|
|
trackCount: allMusicFiles.length * repetitions
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
getRandomMusicFile,
|
|
getAllMusicFiles,
|
|
shuffleArray,
|
|
createPlaylist,
|
|
initializeSharedPlaylist
|
|
};
|