const express = require('express'); const http = require('http'); const WebSocket = require('ws'); const os = require('os'); const path = require('path'); const bodyParser = require('body-parser'); const multer = require('multer'); const fs = require('fs'); const { execSync, spawn } = require('child_process'); const dfpwm = require('dfpwm'); const { fetch } = require('undici'); const app = express(); const server = http.createServer(app); const wss = new WebSocket.Server({ server }); const PORT = Number(process.env.PORT || '8999'); const DEFAULT_ZH_MODEL = process.env.ZH_MODEL || '7b64d954530d490a8c40827e618e55c6'; const HLS_ENABLE = process.env.HLS_ENABLE !== '0'; const HLS_STREAM_MODE = (process.env.HLS_STREAM_MODE || 'event').toLowerCase(); const HLS_OUTPUT_MODE = (process.env.HLS_OUTPUT_MODE || 'srs').toLowerCase(); const SRS_RTMP_BASE = process.env.SRS_RTMP_BASE || 'rtmp://127.0.0.1/live'; const STABROADCAST_PUBLIC_BASE_DEFAULT = 'https://stabroadcast.fse-media.group'; const STABROADCAST_PUBLIC_BASE = process.env.STABROADCAST_PUBLIC_BASE || STABROADCAST_PUBLIC_BASE_DEFAULT; const STABROADCAST_API_BASE = process.env.STABROADCAST_API_BASE || 'http://stabadmin.fse-media.group'; const STABROADCAST_HLS_SUFFIX = process.env.STABROADCAST_HLS_SUFFIX || '.m3u8'; const HLS_TAIL_SILENCE_SEC = Number(process.env.HLS_TAIL_SILENCE_SEC || '0'); const HLS_PREBUILD = process.env.HLS_PREBUILD !== '0'; const appRootDir = path.resolve(__dirname, '..'); const tmpDir = path.join(__dirname, 'tmp'); if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true }); const silence1sWavPath = path.join(tmpDir, 'silence-1s.wav'); const localHlsDir = path.join(__dirname, 'public', 'hls'); if (!fs.existsSync(localHlsDir)) fs.mkdirSync(localHlsDir, { recursive: true }); const uploadDir = path.join(__dirname, 'public', 'uploads'); const audioRootDir = path.join(appRootDir, 'Audio'); const audioGeneralDir = path.join(audioRootDir, 'general'); const audioStationDir = path.join(audioRootDir, 'station'); const listFilePath = path.join(appRootDir, 'list.txt'); const tempBroadcastDir = path.join(appRootDir, 'temp-broadcast'); if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); if (!fs.existsSync(audioRootDir)) fs.mkdirSync(audioRootDir, { recursive: true }); if (!fs.existsSync(audioGeneralDir)) fs.mkdirSync(audioGeneralDir, { recursive: true }); if (!fs.existsSync(audioStationDir)) fs.mkdirSync(audioStationDir, { recursive: true }); if (!fs.existsSync(tempBroadcastDir)) fs.mkdirSync(tempBroadcastDir, { recursive: true }); // Multer storage configuration const storage = multer.diskStorage({ destination: function (req, file, cb) { if (file.fieldname === 'listFile') { cb(null, appRootDir); } else if (file.fieldname === 'generalAudioFiles') { cb(null, audioGeneralDir); } else if (file.fieldname === 'stationAudioFiles') { cb(null, audioStationDir); } else { cb(null, uploadDir); } }, filename: function (req, file, cb) { if (file.fieldname === 'listFile') { cb(null, 'list.txt'); } else { // Use original name, handle encoding if needed (multer handles basic utf8) file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8'); cb(null, file.originalname); } } }); const upload = multer({ storage: storage }); // Middleware app.use(bodyParser.json()); app.use((req, res, next) => { if (req.path.endsWith('.m3u8')) res.type('application/vnd.apple.mpegurl'); else if (req.path.endsWith('.ts')) res.type('video/mp2t'); next(); }); function sendListFile(res) { if (!fs.existsSync(listFilePath)) { return res.status(404).send('list.txt not found'); } res.type('text/plain; charset=utf-8'); res.send(fs.readFileSync(listFilePath, 'utf8')); } app.get('/list.txt', (req, res) => sendListFile(res)); app.get('/uploads/list.txt', (req, res) => sendListFile(res)); function sendBinaryFile(res, filePath) { if (!fs.existsSync(filePath)) return res.status(404).send('Not found'); res.sendFile(filePath); } app.get('/media/audio/general/:file', (req, res) => { const name = path.basename(String(req.params.file || '')); sendBinaryFile(res, path.join(audioGeneralDir, name)); }); app.get('/media/audio/station/:file', (req, res) => { const name = path.basename(String(req.params.file || '')); sendBinaryFile(res, path.join(audioStationDir, name)); }); app.get('/media/temp/:file', (req, res) => { const name = path.basename(String(req.params.file || '')); sendBinaryFile(res, path.join(tempBroadcastDir, name)); }); app.get('/media', (req, res) => { res.json({ success: true, message: 'Use a concrete media path', routes: [ '/media/audio/general/:file', '/media/audio/station/:file', '/media/temp/:file' ] }); }); app.use(express.static(path.join(__dirname, 'public'))); app.set('view engine', 'ejs'); // Data Store (In-memory) let stations = {}; let activeTrains = []; let stationLogs = []; let audioVersion = String(Date.now()); let versions = []; const DATA_FILE = path.join(__dirname, 'server-data.json'); const stationAudioQueues = new Map(); function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function getInterruptModeForPriority(priority) { if (priority >= 2) return 'all'; if (priority >= 1) return 'ifLower'; return 'none'; } function insertTaskByPriority(queue, task) { const p = Number(task?.priority || 0); let idx = 0; while (idx < queue.length) { const q = queue[idx]; const qp = Number(q?.priority || 0); if (p > qp) break; idx++; } queue.splice(idx, 0, task); } function getStationData(stationName) { const cleanName = String(stationName || '').trim(); if (Object.prototype.hasOwnProperty.call(stations, cleanName)) return stations[cleanName]; // Try matching by code and platform if no direct name match const targetCode = getStationCodeFromName(cleanName); // Already lowercase and normalized const targetPlatform = getPlatformNumberFromName(cleanName); if (targetCode) { for (const k in stations) { if (!Object.prototype.hasOwnProperty.call(stations, k)) continue; const st = stations[k]; // Normalize st.code similarly to how targetCode was generated const stCodeRaw = st.code || getStationCodeFromName(st.name) || ''; const stCode = stCodeRaw.replace(/_/g, '-').toLowerCase(); const stPlatform = getPlatformNumberFromName(st.name); if (stCode === targetCode && stPlatform === targetPlatform) { return st; } } } return null; } function getStationWs(stationName, fallbackWs) { const st = getStationData(stationName); if (st && st.ws && st.ws.readyState === WebSocket.OPEN) return st.ws; if (fallbackWs && fallbackWs.readyState === WebSocket.OPEN) return fallbackWs; return null; } function getMergedStationList() { const list = []; for (const k in stations) { if (!Object.prototype.hasOwnProperty.call(stations, k)) continue; const st = stations[k]; const stCodeRaw = st.code || getStationCodeFromName(st.name) || ''; const code = stCodeRaw.replace(/_/g, '-').toLowerCase(); list.push({ name: st.name, code: code || '', platform: st.platform || getPlatformNumberFromName(st.name), lastSeen: st.lastSeen, source: 'cc', tags: ['CC'] }); } return list; } function sendAudioControl(stationName, msg, fallbackWs) { const ws = getStationWs(stationName, fallbackWs); if (!ws) return false; try { ws.send(JSON.stringify(msg)); return true; } catch { return false; } } async function sendAudioBufferToStation(stationName, fallbackWs, task) { const targetWs = getStationWs(stationName, fallbackWs); if (!targetWs) throw new Error(`Station "${stationName}" not connected`); const audioId = String(task.audioId || `${Date.now()}-${Math.random().toString(16).slice(2)}`); const buf = task.buffer || null; const durationSec = Number(task.durationSec || 0); const audioFormat = String(task.audioFormat || 'dfpwm').toLowerCase(); const streamUrl = task.streamUrl ? String(task.streamUrl) : null; const startMsg = { type: 'audio_start', stationName, audioId, byteLength: buf ? buf.length : 0, sampleRate: Number(task.sampleRate || 48000), channels: Number(task.channels || 1), audioFormat, streamUrl, priority: Number(task.priority || 0), interruptMode: task.interruptMode || getInterruptModeForPriority(Number(task.priority || 0)), source: task.source || 'unknown' }; targetWs.send(JSON.stringify(startMsg)); if (streamUrl) { return { audioId, cancelled: false, durationSec }; } const token = task.cancelToken; const SINGLE_SEND_LIMIT = 32 * 1024; const CHUNK_SIZE = 16 * 1024; if (buf.length <= SINGLE_SEND_LIMIT) { if (token && token.cancelled) return { audioId, cancelled: true, durationSec }; targetWs.send(buf); } else { for (let offset = 0; offset < buf.length; offset += CHUNK_SIZE) { if (token && token.cancelled) return { audioId, cancelled: true, durationSec }; const end = Math.min(offset + CHUNK_SIZE, buf.length); targetWs.send(buf.slice(offset, end)); // For streaming, we can send slightly faster initially then steady const isInitial = offset < CHUNK_SIZE * 4; await delay(isInitial ? 20 : 60); } } if (token && token.cancelled) return { audioId, cancelled: true, durationSec }; targetWs.send(JSON.stringify({ type: 'audio_end', stationName, audioId, durationSec })); return { audioId, cancelled: false, durationSec }; } const stationMusicStates = new Map(); function normalizeBaseName(name) { const s = String(name || '').trim(); if (!s) return null; const base = s.replace(/\.(wav|dfpwm)$/i, '').trim(); return base || null; } function getApiBaseUrl() { return String(STABROADCAST_API_BASE || '').replace(/\/+$/g, ''); } function getManagedMediaUrl(kind, fileName) { const base = getApiBaseUrl(); return `${base}/media/${kind}/${encodeURIComponent(fileName)}`; } function listMusicTracks() { const out = new Set(); if (!fs.existsSync(audioGeneralDir)) return []; for (const file of fs.readdirSync(audioGeneralDir)) { if (!file || file.startsWith('.')) continue; const full = path.join(audioGeneralDir, file); if (!fs.existsSync(full) || !fs.statSync(full).isFile()) continue; if (!file.toLowerCase().endsWith('.wav') && !file.toLowerCase().endsWith('.dfpwm')) continue; const base = normalizeBaseName(file); if (base) out.add(base); } return Array.from(out).sort((a, b) => a.localeCompare(b)); } function getWavDurationSec(buffer) { if (!Buffer.isBuffer(buffer) || buffer.length < 44) return 0; if (buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') return 0; let offset = 12; let sampleRate = 0; let channels = 0; let bitsPerSample = 0; let dataSize = 0; while (offset + 8 <= buffer.length) { const chunkId = buffer.toString('ascii', offset, offset + 4); const chunkSize = buffer.readUInt32LE(offset + 4); const dataOffset = offset + 8; if (dataOffset + chunkSize > buffer.length) break; if (chunkId === 'fmt ' && chunkSize >= 16) { channels = buffer.readUInt16LE(dataOffset + 2); sampleRate = buffer.readUInt32LE(dataOffset + 4); bitsPerSample = buffer.readUInt16LE(dataOffset + 14); } else if (chunkId === 'data') { dataSize = chunkSize; break; } offset = dataOffset + chunkSize + (chunkSize % 2); } if (!sampleRate || !channels || !bitsPerSample || !dataSize) return 0; return dataSize / (sampleRate * channels * (bitsPerSample / 8)); } function getPlaybackAudioByBaseName(baseName) { const base = normalizeBaseName(baseName); if (!base) throw new Error('Invalid track name'); const wavCandidates = [ path.join(audioGeneralDir, `${base}.wav`), path.join(audioStationDir, `${base}.wav`) ]; for (const p of wavCandidates) { if (fs.existsSync(p)) { const buffer = fs.readFileSync(p); return { audioFormat: 'stream', streamUrl: getManagedMediaUrl(p.startsWith(audioStationDir) ? 'audio/station' : 'audio/general', path.basename(p)), durationSec: getWavDurationSec(buffer) || 0 }; } } const dfCandidates = [ path.join(audioGeneralDir, `${base}.dfpwm`), path.join(audioStationDir, `${base}.dfpwm`) ]; for (const p of dfCandidates) { if (fs.existsSync(p)) { const buffer = fs.readFileSync(p); return { buffer, audioFormat: 'dfpwm', durationSec: buffer.length / 6000 }; } } throw new Error(`Audio not found: ${base}`); } function stopStationMusic(stationName, fallbackWs) { const cleanStation = String(stationName || '').trim(); const q = getStationAudioQueue(cleanStation); q.queue = q.queue.filter(t => t?.source !== 'music'); if (q.current && q.current.source === 'music') { if (q.current.cancelToken) q.current.cancelToken.cancelled = true; if (q.current.endTimer) { try { clearTimeout(q.current.endTimer); } catch {} q.current.endTimer = null; } } sendAudioControl(cleanStation, { type: 'audio_stop', scope: 'music', mode: 'current', reason: 'music_stop' }, fallbackWs); } function getStationAudioQueue(stationName) { const st = getStationData(stationName); const key = st ? st.name : String(stationName || '').trim(); if (!stationAudioQueues.has(key)) { stationAudioQueues.set(key, { processing: false, current: null, queue: [] }); } return stationAudioQueues.get(key); } function enqueueStationAudioTask(stationName, fallbackWs, task) { const cleanStation = String(stationName || '').trim(); const q = getStationAudioQueue(cleanStation); const newP = Number(task.priority || 0); const cur = q.current; if (task.interruptMode === 'all' || newP >= 2) { if (cur && cur.cancelToken) cur.cancelToken.cancelled = true; if (cur && cur.endTimer) { try { clearTimeout(cur.endTimer); } catch {} cur.endTimer = null; } q.queue.length = 0; sendAudioControl(cleanStation, { type: 'audio_stop', mode: 'all', reason: 'emergency' }, fallbackWs); } else if (cur) { const curP = Number(cur.priority || 0); if (newP > curP) { if (cur.cancelToken) cur.cancelToken.cancelled = true; if (cur.endTimer) { try { clearTimeout(cur.endTimer); } catch {} cur.endTimer = null; } sendAudioControl(cleanStation, { type: 'audio_stop', mode: 'current', flushBelow: newP, reason: 'preempt' }, fallbackWs); if (cur.requeueOnPreempt) { const replay = { ...cur, cancelToken: null }; replay.audioId = `${String(replay.audioId || '')}-replay-${Date.now()}`; insertTaskByPriority(q.queue, replay); } } } insertTaskByPriority(q.queue, task); processStationAudioQueue(cleanStation, fallbackWs).catch(() => {}); } async function processStationAudioQueue(stationName, fallbackWs) { const cleanStation = String(stationName || '').trim(); const q = getStationAudioQueue(cleanStation); if (q.processing) return; q.processing = true; try { while (q.queue.length > 0) { const task = q.queue.shift(); task.cancelToken = { cancelled: false }; q.current = task; try { if (task.consoleStreamStart) broadcastToConsole(task.consoleStreamStart); const result = await sendAudioBufferToStation(cleanStation, fallbackWs, task); if (task.consoleStreamEnd) { if (result.cancelled) { broadcastToConsole(task.consoleStreamEnd); } else { task.endTimer = setTimeout(() => { try { broadcastToConsole(task.consoleStreamEnd); } catch {} }, Math.max(0, Number(result.durationSec || 0) * 1000)); } } if (task.onDone) task.onDone({ ...result, stationName: cleanStation }); } catch (e) { if (task.consoleStreamEnd) broadcastToConsole(task.consoleStreamEnd); if (task.onDone) task.onDone({ cancelled: true, error: e?.message || String(e), stationName: cleanStation }); } finally { q.current = null; } } } finally { q.processing = false; } } function loadData() { if (fs.existsSync(DATA_FILE)) { try { const data = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); if (data.stations) stations = data.stations; if (data.activeTrains) activeTrains = data.activeTrains; if (data.audioVersion) audioVersion = String(data.audioVersion); if (Array.isArray(data.versions)) versions = data.versions; refreshStationIndex(); log('INFO', 'Data loaded from disk'); } catch (e) { log('ERROR', `Failed to load data: ${e.message}`); } } } function saveData() { try { // Create a copy of stations without ws objects const stationsToSave = {}; for (const key in stations) { const { ws, ...rest } = stations[key]; stationsToSave[key] = rest; } const data = { stations: stationsToSave, activeTrains, audioVersion, versions }; fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2)); } catch (e) { log('ERROR', `Failed to save data: ${e.message}`); } } function normalizeVersionString(input) { if (input === undefined || input === null) return ''; if (typeof input !== 'string') input = String(input); input = input.trim(); if (input.length > 64) input = input.slice(0, 64); return input; } function resolveManagedPath(inputPath) { const normalized = String(inputPath || '').replace(/\\/g, '/').replace(/^\/+/, '').trim(); if (!normalized) return null; if (normalized === 'list.txt' || normalized === 'uploads/list.txt') { return { logicalPath: 'list.txt', fullPath: listFilePath }; } const prefixes = [ { prefix: 'Audio/general/', baseDir: audioGeneralDir, logicalPrefix: 'Audio/general/' }, { prefix: 'uploads/audio/general/', baseDir: audioGeneralDir, logicalPrefix: 'Audio/general/' }, { prefix: 'Audio/station/', baseDir: audioStationDir, logicalPrefix: 'Audio/station/' }, { prefix: 'uploads/audio/station/', baseDir: audioStationDir, logicalPrefix: 'Audio/station/' } ]; for (const entry of prefixes) { if (!normalized.startsWith(entry.prefix)) continue; const relativeName = normalized.slice(entry.prefix.length).trim(); if (!relativeName || relativeName.includes('/') || relativeName.includes('\\')) return null; return { logicalPath: `${entry.logicalPrefix}${relativeName}`, fullPath: path.join(entry.baseDir, relativeName) }; } return null; } function getManagedFiles() { const files = []; if (fs.existsSync(listFilePath)) { const stats = fs.statSync(listFilePath); files.push({ name: 'list.txt', path: 'list.txt', type: 'config', size: stats.size, mtime: stats.mtimeMs }); } if (fs.existsSync(audioGeneralDir)) { fs.readdirSync(audioGeneralDir).forEach(file => { if (file.endsWith('.dfpwm')) { const wavName = file.replace(/\.dfpwm$/, '.wav'); if (fs.existsSync(path.join(audioGeneralDir, wavName))) return; } const filePath = path.join(audioGeneralDir, file); const stats = fs.statSync(filePath); files.push({ name: file, path: `Audio/general/${file}`, type: 'general', size: stats.size, mtime: stats.mtimeMs }); }); } if (fs.existsSync(audioStationDir)) { fs.readdirSync(audioStationDir).forEach(file => { if (file.endsWith('.dfpwm')) { const wavName = file.replace(/\.dfpwm$/, '.wav'); if (fs.existsSync(path.join(audioStationDir, wavName))) return; } const filePath = path.join(audioStationDir, file); const stats = fs.statSync(filePath); files.push({ name: file, path: `Audio/station/${file}`, type: 'station', size: stats.size, mtime: stats.mtimeMs }); }); } return files; } function snapshotFiles() { const snapshot = {}; const files = getManagedFiles(); for (const f of files) { snapshot[f.path] = { mtime: f.mtime, size: f.size, type: f.type, name: f.name }; } return snapshot; } function diffSnapshots(prevSnap, nextSnap) { const prev = prevSnap || {}; const next = nextSnap || {}; const added = []; const modified = []; const deleted = []; for (const p of Object.keys(next)) { if (!prev[p]) added.push(p); else if (prev[p].mtime !== next[p].mtime || prev[p].size !== next[p].size) modified.push(p); } for (const p of Object.keys(prev)) { if (!next[p]) deleted.push(p); } added.sort(); modified.sort(); deleted.sort(); return { added, modified, deleted }; } function log(type, message) { const timestamp = new Date().toISOString(); const logEntry = `[${timestamp}] [${type}] ${message}`; console.log(logEntry); stationLogs.unshift(logEntry); if (stationLogs.length > 100) stationLogs.pop(); // Broadcast logs to web console broadcastToConsole({ type: 'log', data: logEntry }); } const trainEventCooldown = new Map(); // trainId|stationName|type -> timestamp function normalizeName(value) { return String(value || '').trim(); } function getStationCodeFromName(name) { if (!name) return null; // 1. Try to find ID in parentheses or brackets (e.g. "Station (01-01/01-02)") const parenMatch = name.match(/[\(\[]([a-zA-Z0-9\-\/]+)[\)\]]/); if (parenMatch) return parenMatch[1].split('/')[0].replace('_', '-').trim().toLowerCase(); // 2. Try extraction from anywhere in string (e.g. "01-01" or "01_01") const m = name.match(/([a-zA-Z0-9]+[-_][a-zA-Z0-9]+)/); if (m) return m[1].replace('_', '-').toLowerCase(); // 3. Fallback to first part of name if it looks like a code or a name const parts = name.split(/[\s?]+/); const firstPart = parts[0]; // Ignore common generic parts if (firstPart === 'Station' || firstPart === 'Platform' || firstPart === 'Track') { if (parts[1]) return parts[1].toLowerCase(); } if (firstPart && firstPart.length > 0) { const code = firstPart.replace('_', '-').toLowerCase(); // log('DEBUG', `Extracted code "${code}" from "${name}"`); return code; } return null; } function getPlatformNumberFromName(name) { if (!name) return "1"; // 1. Try "Platform X" or "Track X" (case insensitive) const m1 = name.match(/(?:platform|track)\s*([0-9]+[a-zA-Z]?)/i); if (m1) return m1[1]; // 2. Fallback to digits at the end const m2 = name.match(/([0-9]+[a-zA-Z]?)$/); return m2 ? m2[1] : "1"; } function normalizeStationCode(code) { const value = String(code || '').trim().replace(/_/g, '-').toLowerCase(); return value || null; } function normalizeStationPlatform(platform) { const value = String(platform || '').trim().toLowerCase(); return value || '1'; } function normalizeStationRecord(station) { if (!station || !station.name) return; station.platform = normalizeStationPlatform(station.platform || getPlatformNumberFromName(station.name)); station.code = normalizeStationCode(station.code || getStationCodeFromName(station.name)) || station.code || ''; } function refreshStationIndex() { for (const station of Object.values(stations)) { normalizeStationRecord(station); } } function getBaseTrainId(trainId) { if (!trainId) return "unknown"; // Ignore split off trains which are temporary and cause false announcements if (trainId.includes("Split off from")) return null; // Remove car-specific info like "01 3501F" from "CK 1 01 3501F" // Also remove brackets like "[Local] Train 1" -> "Train 1" let clean = trainId.replace(/^\[.*?\]\s*/, ''); // Look for patterns like "CK 1 01" and keep only "CK 1" const parts = clean.split(/\s+/); if (parts.length >= 3) { // If it looks like "NAME NUM CAR ...", keep "NAME NUM" if (parts[0].length <= 8 && !isNaN(parts[1])) { return `${parts[0]} ${parts[1]}`; } } // Handle dash patterns like "123-01" -> "123" const dashMatch = parts[0].match(/^([\w\d]+)\-[\w\d]+$/); if (dashMatch) return dashMatch[1]; return parts[0] + (parts[1] ? ' ' + parts[1] : ''); } // Radio Listeners const radioListeners = {}; const hlsStreams = {}; function sanitizeStreamName(name) { return String(name || '') .trim() .replace(/\s+/g, '_') .replace(/[^a-zA-Z0-9_\-]/g, '_') .replace(/_+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 80) || 'UNK'; } function getPrimaryStationCode(code) { if (!code) return null; const first = String(code).split(/[,\s|/]+/).map(s => s.trim()).filter(Boolean)[0]; return first || null; } function getStationStreamName(stationName) { const st = getStationData(stationName); const primaryCode = getPrimaryStationCode(st?.code); return sanitizeStreamName(primaryCode || stationName); } function getStationHlsUrl(streamName) { const base = String(STABROADCAST_PUBLIC_BASE || '').replace(/\/+$/g, ''); return `${base}/${encodeURIComponent(streamName)}${STABROADCAST_HLS_SUFFIX}`; } function guessSrsHttpBase(req) { const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim(); const hostRaw = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim(); if (!hostRaw) return null; const m = hostRaw.match(/^(.+?)(?::(\d+))?$/); const host = m?.[1] || hostRaw; const port = m?.[2]; const withPort = port ? `${host}:8080` : `${host}:8080`; return `${proto}://${withPort}/live`; } function getStationHlsUrlForRequest(streamName, req) { const base = String(process.env.STABROADCAST_PUBLIC_BASE || guessSrsHttpBase(req) || STABROADCAST_PUBLIC_BASE_DEFAULT).replace(/\/+$/g, ''); return `${base}/${encodeURIComponent(streamName)}${STABROADCAST_HLS_SUFFIX}`; } function getLocalHlsUrlForRequest(streamName, req) { const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim(); const hostRaw = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim(); const base = hostRaw ? `${proto}://${hostRaw}` : ''; return `${base}/hls/${encodeURIComponent(streamName)}/index.m3u8`; } function ensureSilenceWav() { if (fs.existsSync(silence1sWavPath)) return; execSync(`ffmpeg -y -v quiet -f lavfi -i anullsrc=r=48000:cl=mono -t 1 -c:a pcm_s16le "${silence1sWavPath}"`); } function getOrCreateHlsStreamState(streamName) { if (!hlsStreams[streamName]) { hlsStreams[streamName] = { streamName, stationName: null, stationCode: null, hlsUrl: getStationHlsUrl(streamName), status: 'idle', lastStartAt: null, lastEndAt: null, lastSeq: null, lastError: null, lastExitCode: null, queue: Promise.resolve(), currentProcess: null }; } return hlsStreams[streamName]; } function buildConcatListFile(seq) { ensureSilenceWav(); const files = []; for (const item of seq) { if (item === '__DELAY_1__') { files.push(silence1sWavPath); continue; } const candidates = [ path.join(audioStationDir, `${item}.wav`), path.join(audioGeneralDir, `${item}.wav`) ]; const found = candidates.find(p => fs.existsSync(p)); if (found) { files.push(found); } else { log('WARN', `HLS audio missing on server (wav required): ${item}`); } } if (HLS_TAIL_SILENCE_SEC > 0) { for (let i = 0; i < HLS_TAIL_SILENCE_SEC; i++) files.push(silence1sWavPath); } if (files.length === 0) return null; const listPath = path.join(tmpDir, `concat-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`); const content = files .map(p => `file '${p.replace(/\\/g, '/').replace(/'/g, "\\'")}'`) .join('\n'); fs.writeFileSync(listPath, content, 'utf8'); return listPath; } function buildSequenceWavFile(seq) { const listPath = buildConcatListFile(seq); if (!listPath) return null; const fileName = `sequence-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`; const outWavPath = path.join(tempBroadcastDir, fileName); try { execSync(`ffmpeg -hide_banner -loglevel error -y -f concat -safe 0 -i "${listPath}" -ac 1 -ar 48000 -c:a pcm_s16le "${outWavPath}"`); const buffer = fs.readFileSync(outWavPath); const durationSec = getWavDurationSec(buffer) || 0; setTimeout(() => { try { fs.unlinkSync(outWavPath); } catch {} }, Math.max(10 * 60 * 1000, Math.ceil(durationSec * 2000))); return { audioFormat: 'stream', streamUrl: getManagedMediaUrl('temp', fileName), durationSec }; } finally { try { fs.unlinkSync(listPath); } catch {} } } function runFfmpegRtmpPush(streamName, seq) { const state = getOrCreateHlsStreamState(streamName); const listPath = buildConcatListFile(seq); if (!listPath) { state.status = 'idle'; state.lastError = 'No playable wav files in sequence'; return Promise.resolve(); } const rtmpUrl = `${SRS_RTMP_BASE.replace(/\/+$/g, '')}/${encodeURIComponent(streamName)}`; state.lastSeq = seq; state.lastError = null; state.lastExitCode = null; if (HLS_PREBUILD) { const tmpAacPath = path.join(tmpDir, `prebuild-${Date.now()}-${Math.random().toString(16).slice(2)}.aac`); const buildArgs = [ '-hide_banner', '-loglevel', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-ac', '1', '-ar', '48000', '-c:a', 'aac', '-b:a', '128k', '-f', 'adts', tmpAacPath ]; state.status = 'building'; state.lastStartAt = Date.now(); const buildProc = spawn('ffmpeg', buildArgs, { stdio: ['ignore', 'ignore', 'pipe'] }); let buildStderr = ''; buildProc.stderr.on('data', (buf) => { buildStderr += buf.toString('utf8'); if (buildStderr.length > 8000) buildStderr = buildStderr.slice(-8000); }); return new Promise((resolve) => { buildProc.on('close', (buildCode) => { if (buildCode !== 0) { state.lastExitCode = buildCode; state.status = 'idle'; state.lastEndAt = Date.now(); state.lastError = (buildStderr || `ffmpeg exited with code ${buildCode}`).trim(); log('ERROR', `HLS build failed for ${streamName}: ${state.lastError}`); try { fs.unlinkSync(listPath); } catch {} try { fs.unlinkSync(tmpAacPath); } catch {} resolve(); return; } const pushArgs = [ '-hide_banner', '-loglevel', 'error', '-re', '-i', tmpAacPath, '-c:a', 'copy', '-f', 'flv', rtmpUrl ]; state.status = 'pushing'; const proc = spawn('ffmpeg', pushArgs, { stdio: ['ignore', 'ignore', 'pipe'] }); state.currentProcess = proc; let stderr = ''; proc.stderr.on('data', (buf) => { stderr += buf.toString('utf8'); if (stderr.length > 8000) stderr = stderr.slice(-8000); }); proc.on('close', (code) => { state.lastExitCode = code; state.status = 'idle'; state.lastEndAt = Date.now(); state.currentProcess = null; if (code !== 0) { state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim(); log('ERROR', `HLS push failed for ${streamName}: ${state.lastError}`); } try { fs.unlinkSync(listPath); } catch {} try { fs.unlinkSync(tmpAacPath); } catch {} resolve(); }); }); }); } const args = [ '-hide_banner', '-loglevel', 'error', '-re', '-f', 'concat', '-safe', '0', '-i', listPath, '-ac', '1', '-ar', '48000', '-c:a', 'aac', '-b:a', '128k', '-f', 'flv', rtmpUrl ]; state.status = 'pushing'; state.lastStartAt = Date.now(); const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] }); state.currentProcess = proc; let stderr = ''; proc.stderr.on('data', (buf) => { stderr += buf.toString('utf8'); if (stderr.length > 8000) stderr = stderr.slice(-8000); }); return new Promise((resolve) => { proc.on('close', (code) => { state.lastExitCode = code; state.status = 'idle'; state.lastEndAt = Date.now(); state.currentProcess = null; if (code !== 0) { state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim(); log('ERROR', `HLS push failed for ${streamName}: ${state.lastError}`); } try { fs.unlinkSync(listPath); } catch {} resolve(); }); }); } function clearLocalHlsStreamDir(streamName) { const dir = path.join(localHlsDir, streamName); if (!fs.existsSync(dir)) return dir; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const ent of entries) { const p = path.join(dir, ent.name); if (ent.isDirectory()) { fs.rmSync(p, { recursive: true, force: true }); } else { try { fs.unlinkSync(p); } catch {} } } return dir; } function runFfmpegLocalHlsBuild(streamName, seq) { const state = getOrCreateHlsStreamState(streamName); const listPath = buildConcatListFile(seq); if (!listPath) { state.status = 'idle'; state.lastError = 'No playable wav files in sequence'; return Promise.resolve(); } state.lastSeq = seq; state.lastError = null; state.lastExitCode = null; state.status = 'building'; state.lastStartAt = Date.now(); const outDir = clearLocalHlsStreamDir(streamName); if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); const playlistPath = path.join(outDir, 'index.m3u8'); const segPattern = path.join(outDir, 'seg-%05d.ts'); const args = [ '-hide_banner', '-loglevel', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-ac', '1', '-ar', '48000', '-c:a', 'aac', '-b:a', '128k', '-f', 'hls', '-hls_time', '2', '-hls_list_size', '0', '-hls_playlist_type', 'vod', '-hls_flags', 'independent_segments', '-hls_segment_filename', segPattern, playlistPath ]; const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] }); state.currentProcess = proc; let stderr = ''; proc.stderr.on('data', (buf) => { stderr += buf.toString('utf8'); if (stderr.length > 8000) stderr = stderr.slice(-8000); }); return new Promise((resolve) => { proc.on('close', (code) => { state.lastExitCode = code; state.status = 'idle'; state.lastEndAt = Date.now(); state.currentProcess = null; if (code !== 0) { state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim(); log('ERROR', `Local HLS build failed for ${streamName}: ${state.lastError}`); } try { fs.unlinkSync(listPath); } catch {} resolve(); }); }); } function enqueueHlsPush(stationName, seq) { if (!HLS_ENABLE) return; if (!Array.isArray(seq) || seq.length === 0) return; const streamName = getStationStreamName(stationName); const st = getStationData(stationName); const state = getOrCreateHlsStreamState(streamName); state.stationName = stationName; state.stationCode = getPrimaryStationCode(st?.code); state.hlsUrl = state.hlsUrl || getStationHlsUrl(streamName); if (HLS_STREAM_MODE === 'replace' && state.currentProcess) { try { state.currentProcess.kill('SIGKILL'); } catch {} } state.queue = state.queue .then(() => (HLS_OUTPUT_MODE === 'local' ? runFfmpegLocalHlsBuild(streamName, seq) : runFfmpegRtmpPush(streamName, seq))) .catch((e) => { state.status = 'idle'; state.lastError = e?.message || String(e); log('ERROR', `HLS push error for ${streamName}: ${state.lastError}`); }); } // WebSocket handling wss.on('connection', (ws, req) => { const ip = req.socket.remoteAddress; // Check if it's a radio connection const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); if (url.pathname === '/api/radio') { const station = url.searchParams.get('station'); if (station) { const cleanStation = station.trim(); if (!radioListeners[cleanStation]) radioListeners[cleanStation] = []; // To prevent zombie connections, close existing connections from the same IP // Since CC might reconnect quickly and leave old ones hanging radioListeners[cleanStation] = radioListeners[cleanStation].filter(existingWs => { if (existingWs.readyState !== WebSocket.OPEN) return false; // If it's the same IP connecting again, close the old one to save CC resources if (existingWs._socket && req.socket && existingWs._socket.remoteAddress === req.socket.remoteAddress) { existingWs.close(); return false; } return true; }); radioListeners[cleanStation].push(ws); log('INFO', `Radio listener connected for station: ${cleanStation}`); // Send an empty initial frame to let the client know it's connected and ready // DFPWM silence is 0xAA. 24000 bytes = 4 seconds of silence to pre-fill buffer. ws.send(Buffer.alloc(24000, 0xAA)); ws.on('close', () => { radioListeners[cleanStation] = radioListeners[cleanStation].filter(l => l !== ws); log('INFO', `Radio listener disconnected for station: ${cleanStation}`); }); // Ping to keep alive const pingInterval = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.ping(); } else { clearInterval(pingInterval); } }, 30000); ws.on('close', () => clearInterval(pingInterval)); return; } } ws.on('message', (message) => { try { const data = JSON.parse(message); handleMessage(ws, data); } catch (e) { log('ERROR', `Failed to parse message: ${e.message}`); } }); ws.on('close', () => { // Handle disconnection if necessary }); }); function handleMessage(ws, data) { // Check if it's a console connection if (data.type === 'console_connect') { ws.isConsole = true; // Send initial state ws.send(JSON.stringify({ type: 'init_state', stations, activeTrains, logs: stationLogs })); return; } // Handle Station Messages switch (data.type) { case 'station_register': handleStationRegister(ws, data); break; case 'train_arrival': handleTrainArrival(data); break; case 'train_departure': handleTrainDeparture(data); break; case 'request_station_time': handleStationTimeRequest(ws, data); break; case 'play_sequence': handlePlaySequence(ws, data); break; case 'request_list_txt': // TODO: Send list.txt content break; case 'heartbeat': handleHeartbeat(ws, data); break; default: log('WARNING', `Unknown message type: ${data.type}`); } } function getDestinationFromSchedule(schedule) { if (!schedule || !schedule.entries) return "Unknown"; const entries = schedule.entries; let dest = "Unknown"; // Convert entries to array if it's an object with numeric keys const entryList = Array.isArray(entries) ? entries : Object.values(entries); // 1. Look for explicit destination instructions for (const entry of entryList) { if (entry.instruction?.id === 'create:destination') { dest = entry.instruction.data?.text || entry.instruction.text || dest; } } // 2. If still Unknown, find the very last station instruction if (dest === "Unknown") { for (let i = entryList.length - 1; i >= 0; i--) { if (entryList[i].instruction?.id === 'create:station') { dest = entryList[i].instruction.data?.text || entryList[i].instruction.text || dest; break; } } } return dest; } function getCorrectStationName(name) { const cleanName = String(name || '').trim(); const station = findStationByFlexibleName(cleanName); if (station && station.name) { return station.name; } return cleanName; } function handleHeartbeat(ws, data) { if (data.stationName) { const cleanName = data.stationName.trim(); if (!stations[cleanName]) { // Auto-register if missing (e.g. server restarted) stations[cleanName] = { name: cleanName, code: data.stationCode || '', platform: data.stationPlatform || getPlatformNumberFromName(cleanName), lastSeen: Date.now(), ws: ws }; normalizeStationRecord(stations[cleanName]); log('INFO', `Station auto-registered via heartbeat: "${cleanName}"`); broadcastUpdate(); } else { stations[cleanName].lastSeen = Date.now(); if (data.stationCode) stations[cleanName].code = data.stationCode; if (data.stationPlatform) stations[cleanName].platform = data.stationPlatform; if (stations[cleanName].ws !== ws) { stations[cleanName].ws = ws; // Update WS in case of reconnect log('INFO', `Station WebSocket updated via heartbeat: "${cleanName}"`); broadcastUpdate(); } normalizeStationRecord(stations[cleanName]); } } } function handleStationRegister(ws, data) { const { stationName, stationCode, stationPlatform } = data; const cleanName = stationName.trim(); stations[cleanName] = { name: cleanName, code: stationCode, platform: stationPlatform || getPlatformNumberFromName(cleanName), lastSeen: Date.now(), ws: ws // Keep reference if we need to send back }; normalizeStationRecord(stations[cleanName]); log('SUCCESS', `Station registered: "${cleanName}" (${stationCode})`); // Send ack ws.send(JSON.stringify({ type: 'station_register_ack', stationName: cleanName })); saveData(); broadcastUpdate(); } function handleTrainArrival(data) { let { trainId, stationName, arrivalTime, destination, source, schedule } = data; stationName = getCorrectStationName(stationName); const baseId = getBaseTrainId(trainId); const cooldownKey = `${baseId}|${stationName}|arrival`; const lastEventTime = trainEventCooldown.get(cooldownKey); if (lastEventTime && Date.now() - lastEventTime < 45000) return; trainEventCooldown.set(cooldownKey, Date.now()); log('INFO', `Train ${trainId} (Consist ${baseId}) arrived at ${stationName}`); // Add or update active train using base ID to group cars const existingIndex = activeTrains.findIndex(t => getBaseTrainId(t.trainId) === baseId); // Create history entry let trainData = { trainId, currentStation: stationName, destination: destination || (existingIndex >= 0 ? activeTrains[existingIndex].destination : "Unknown"), status: "At Station", lastUpdate: Date.now(), schedule: schedule || (existingIndex >= 0 ? activeTrains[existingIndex].schedule : null) }; if (existingIndex >= 0) { activeTrains[existingIndex] = { ...activeTrains[existingIndex], ...trainData }; } else { activeTrains.push(trainData); } broadcastUpdate(); } function getBaseName(name) { if (!name) return ""; return String(name).toLowerCase() .replace(/^[\d\w]+[-_][\d\w]+\s+/, '') // Strip leading code "01-01 " .replace(/\s*platform\s*[\d\w]+/gi, '') .replace(/\s*track\s*[\d\w]+/gi, '') .replace(/\s*站台\s*[\d\w]+/gi, '') .replace(/\s*股道\s*[\d\w]+/gi, '') .replace(/@/g, ' ') .replace(/\s*(station|站|tread|track)$/gi, '') .replace(/[\(\[].*?[\)\]]/g, ' ') .replace(/\s+\d+$/g, '') .replace(/[^\w\s\u4e00-\u9fa5]/g, ' ') .replace(/\s+/g, ' ') .trim(); } function isFuzzyMatch(str, pattern) { if (!str || !pattern) return false; const s = String(str).toLowerCase().trim(); const p = String(pattern).toLowerCase().trim(); if (s === p) return true; // 1. Wildcard match if (p.includes('*')) { const regex = new RegExp('^' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*') + '$', 'i'); if (regex.test(s)) return true; } // 2. Base name comparison const b1 = getBaseName(s); const b2 = getBaseName(p); if (b1 && b2 && b1 === b2) return true; // 3. Substring match of base names (if one is long enough) if (b1 && b2) { if (b1.length >= 4 && b2.length >= 4) { if (b1.includes(b2) || b2.includes(b1)) return true; } } // 4. Stripped comparison const s1 = s.replace(/[\s\-\._\(\)\[\]]/g, ''); const s2 = p.replace(/[\s\-\._\(\)\[\]]/g, ''); if (s1 && s2 && s1 === s2) return true; // 5. Station code comparison const c1 = getStationCodeFromName(s); const c2 = getStationCodeFromName(p); if (c1 && c2 && c1 === c2) { // Only trust code match if it looks like a real code (contains numbers or is very short) const isRealCode = (str) => /[\d]/.test(str) || str.length <= 4; if (isRealCode(c1)) return true; // If it's a long name-based code, also require base name similarity if (b1 && b2 && (b1.includes(b2) || b2.includes(b1))) return true; } return false; } function findStationByFlexibleName(targetName) { if (!targetName) return null; // 1. Try exact match if (stations[targetName]) return stations[targetName]; // 2. Search through registered stations using fuzzy match for (const [regName, data] of Object.entries(stations)) { if (isFuzzyMatch(regName, targetName)) { // log('DEBUG', `[Station-Search] Matched "${targetName}" to registered "${regName}"`); return data; } } // Log available stations for debugging const available = Object.keys(stations).map(k => `"${k}" (base: "${getBaseName(k)}")`).join(', '); log('DEBUG', `[Station-Search] Could not find match for "${targetName}" (base: "${getBaseName(targetName)}"). Available: [${available}]`); return null; } function handleTrainDeparture(data) { let { trainId, stationName, destination, schedule, source } = data; stationName = getCorrectStationName(stationName); const baseId = getBaseTrainId(trainId); const cooldownKey = `${baseId}|${stationName}|departure`; const lastEventTime = trainEventCooldown.get(cooldownKey); if (lastEventTime && Date.now() - lastEventTime < 45000) return; trainEventCooldown.set(cooldownKey, Date.now()); log('INFO', `Train ${trainId} (Consist ${baseId}) departed from ${stationName}`); const existingIndex = activeTrains.findIndex(t => getBaseTrainId(t.trainId) === baseId); if (existingIndex >= 0) { const train = activeTrains[existingIndex]; train.status = "Departed"; train.currentStation = stationName; train.lastUpdate = Date.now(); train.lastStation = stationName; train.departureTime = Date.now(); train.destination = destination || train.destination; train.schedule = schedule || train.schedule; } broadcastUpdate(); } function handlePlaySequence(ws, data) { const { stationName, seq, requestId } = data; const cleanStation = stationName.trim(); const correctStation = getCorrectStationName(cleanStation); const streamName = getStationStreamName(cleanStation); const hlsUrl = getStationHlsUrl(streamName); log('INFO', `Playing sequence for ${correctStation}: ${seq.join(', ')}`); const builtAudio = buildSequenceWavFile(seq); if (builtAudio) { const priority = Number.isFinite(Number(data.priority)) ? Number(data.priority) : (String(data.category || '').toLowerCase() === 'station' ? 0 : 1); const interruptMode = data.interruptMode || getInterruptModeForPriority(priority); const source = data.source || data.category || 'sequence'; const audioId = String(requestId || `${Date.now()}-${Math.random().toString(16).slice(2)}`); enqueueStationAudioTask(cleanStation, ws, { audioFormat: builtAudio.audioFormat, streamUrl: builtAudio.streamUrl, durationSec: builtAudio.durationSec, priority, interruptMode, source, audioId, requeueOnPreempt: priority <= 0, consoleStreamStart: { type: 'stream_start', station: cleanStation, seq: seq, streamName, hlsUrl }, consoleStreamEnd: { type: 'stream_end', station: cleanStation } }); } else { log('WARN', `Unable to build WAV sequence for ${cleanStation}`); } } function handleStationTimeRequest(ws, data) { // Simple logic: default to 60s or lookup schedule const stationTime = 60; ws.send(JSON.stringify({ type: 'station_time_response', trainId: data.trainId, stationTime: stationTime })); } function broadcastToConsole(msg) { wss.clients.forEach(client => { if (client.readyState === WebSocket.OPEN && client.isConsole) { client.send(JSON.stringify(msg)); } }); } function broadcastUpdate() { const list = getMergedStationList().sort((a, b) => a.name.localeCompare(b.name)); const state = { type: 'state_update', stations: list, activeTrains }; broadcastToConsole(state); } app.get('/api/streams', (req, res) => { const streams = []; const seen = new Set(); const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim(); const hostRaw = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim(); const localBase = hostRaw ? `${proto}://${hostRaw}/hls` : '/hls'; const publicBaseResolved = HLS_OUTPUT_MODE === 'local' ? localBase : String(process.env.STABROADCAST_PUBLIC_BASE || guessSrsHttpBase(req) || STABROADCAST_PUBLIC_BASE_DEFAULT); Object.keys(stations).forEach(stationName => { const st = stations[stationName]; const streamName = getStationStreamName(stationName); const state = getOrCreateHlsStreamState(streamName); state.stationName = stationName; state.stationCode = getPrimaryStationCode(st?.code); state.hlsUrl = HLS_OUTPUT_MODE === 'local' ? getLocalHlsUrlForRequest(streamName, req) : getStationHlsUrlForRequest(streamName, req); seen.add(streamName); streams.push({ streamName, stationName: state.stationName, stationCode: state.stationCode, hlsUrl: state.hlsUrl, status: state.status, lastStartAt: state.lastStartAt, lastEndAt: state.lastEndAt, lastSeq: state.lastSeq, lastError: state.lastError, lastExitCode: state.lastExitCode }); }); Object.values(hlsStreams).forEach(state => { if (seen.has(state.streamName)) return; streams.push({ streamName: state.streamName, stationName: state.stationName, stationCode: state.stationCode, hlsUrl: HLS_OUTPUT_MODE === 'local' ? getLocalHlsUrlForRequest(state.streamName, req) : getStationHlsUrlForRequest(state.streamName, req), status: state.status, lastStartAt: state.lastStartAt, lastEndAt: state.lastEndAt, lastSeq: state.lastSeq, lastError: state.lastError, lastExitCode: state.lastExitCode }); }); streams.sort((a, b) => String(a.streamName).localeCompare(String(b.streamName))); res.json({ ok: true, hlsEnable: HLS_ENABLE, streamMode: HLS_STREAM_MODE, publicBase: publicBaseResolved, srsRtmpBase: SRS_RTMP_BASE, streams }); }); app.get('/stablist', (req, res) => { res.redirect('/stablist.html'); }); // Routes for direct API access if needed app.get('/', (req, res) => { const host = String(req.headers.host || '').toLowerCase(); if (host.startsWith('stablist.fse-media.group')) { res.sendFile(path.join(__dirname, 'public', 'stablist.html')); return; } res.sendFile(path.join(__dirname, 'public', 'index.html')); }); function convertToDfpwm(filePath) { try { if (!filePath.toLowerCase().endsWith('.wav')) return; const rawPath = filePath.replace(/\.wav$/i, '.raw'); const dfpwmPath = filePath.replace(/\.wav$/i, '.dfpwm'); // Convert to raw signed 8-bit 48kHz mono using FFmpeg // -y: overwrite output // -v quiet: suppress output // -f s8: signed 8-bit pcm // -ac 1: mono // -ar 48000: 48kHz execSync(`ffmpeg -y -v quiet -i "${filePath}" -f s8 -acodec pcm_s8 -ac 1 -ar 48000 "${rawPath}"`); if (fs.existsSync(rawPath)) { const buffer = fs.readFileSync(rawPath); // dfpwm.Encoder.encode requires a Buffer in Node.js const encoder = new dfpwm.Encoder(); // We need to pass the raw buffer to the encoder. The buffer already contains 8-bit PCM data. // Some versions of dfpwm throw "Argument #1 must be a Buffer" if it's an Array or TypedArray. const encoded = encoder.encode(buffer); fs.writeFileSync(dfpwmPath, encoded); fs.unlinkSync(rawPath); // Clean up temp file log('SUCCESS', `Converted ${path.basename(filePath)} to DFPWM (FFmpeg)`); } else { log('ERROR', `FFmpeg failed to generate raw file for ${path.basename(filePath)}`); } } catch (e) { log('ERROR', `DFPWM conversion failed for ${path.basename(filePath)}: ${e.message}`); } } // Routes app.get('/api/version', (req, res) => { res.json({ version: audioVersion }); }); app.post('/api/publish-version', (req, res) => { const version = normalizeVersionString(req.body?.version) || String(Date.now()); const snap = snapshotFiles(); const prev = versions.length > 0 ? versions[versions.length - 1].snapshot : null; const changes = diffSnapshots(prev, snap); const entry = { id: String(Date.now()), version, note: normalizeVersionString(req.body?.note), createdAt: Date.now(), snapshot: snap, changes }; versions.push(entry); audioVersion = version; saveData(); log('INFO', `Audio version published: ${version}`); res.json({ success: true, version }); }); app.get('/api/versions', (req, res) => { const list = versions .slice() .reverse() .map(v => ({ id: v.id, version: v.version, note: v.note || '', createdAt: v.createdAt, changes: v.changes || { added: [], modified: [], deleted: [] } })); res.json({ success: true, activeVersion: audioVersion, versions: list }); }); app.post('/api/versions', (req, res) => { const version = normalizeVersionString(req.body?.version) || String(Date.now()); const snap = snapshotFiles(); const prev = versions.length > 0 ? versions[versions.length - 1].snapshot : null; const changes = diffSnapshots(prev, snap); const entry = { id: String(Date.now()), version, note: normalizeVersionString(req.body?.note), createdAt: Date.now(), snapshot: snap, changes }; versions.push(entry); audioVersion = version; saveData(); log('INFO', `Audio version published: ${version}`); res.json({ success: true, version, id: entry.id }); }); app.put('/api/versions/:id', (req, res) => { const id = req.params.id; const idx = versions.findIndex(v => v.id === id); if (idx < 0) return res.status(404).json({ success: false, message: 'Version not found' }); const wasActive = versions[idx].version === audioVersion; const nextVersion = normalizeVersionString(req.body?.version); if (nextVersion) versions[idx].version = nextVersion; const nextNote = normalizeVersionString(req.body?.note); if (req.body?.note !== undefined) versions[idx].note = nextNote; if (wasActive) audioVersion = versions[idx].version; saveData(); res.json({ success: true }); }); app.get('/api/versions/:id/changes', (req, res) => { const id = req.params.id; const entry = versions.find(v => v.id === id); if (!entry) return res.status(404).json({ success: false, message: 'Version not found' }); res.json({ success: true, changes: entry.changes || { added: [], modified: [], deleted: [] } }); }); app.get('/api/versions/:id/files', (req, res) => { const id = req.params.id; const entry = versions.find(v => v.id === id); if (!entry) return res.status(404).json({ success: false, message: 'Version not found' }); res.json({ success: true, files: entry.snapshot || {} }); }); app.post('/api/save-data', (req, res) => { saveData(); log('INFO', 'Manual data save triggered'); res.json({ success: true }); }); app.post('/api/upload/list', upload.single('listFile'), (req, res) => { log('SUCCESS', 'Updated list.txt'); res.json({ success: true, message: 'list.txt updated' }); }); app.post('/api/upload/general-audio', upload.array('generalAudioFiles'), (req, res) => { const files = req.files || []; log('SUCCESS', `Uploaded ${files.length} general audio files`); res.json({ success: true, message: `${files.length} general audio files uploaded` }); }); app.post('/api/upload/station-audio', upload.array('stationAudioFiles'), (req, res) => { const files = req.files || []; log('SUCCESS', `Uploaded ${files.length} station audio files`); res.json({ success: true, message: `${files.length} station audio files uploaded` }); }); app.get('/api/files', (req, res) => { res.json({ files: getManagedFiles() }); }); app.delete('/api/files', (req, res) => { const filePath = req.query.path; if (!filePath) return res.status(400).json({ success: false, message: 'Path required' }); const resolved = resolveManagedPath(filePath); if (!resolved) return res.status(403).json({ success: false, message: 'Invalid path' }); const { fullPath, logicalPath } = resolved; if (fs.existsSync(fullPath)) { try { fs.unlinkSync(fullPath); log('INFO', `Deleted file: ${logicalPath}`); // Also try to delete counterpart (.dfpwm if .wav deleted, or vice versa) if (logicalPath.endsWith('.wav')) { const dfpwmPath = fullPath.replace(/\.wav$/, '.dfpwm'); if (fs.existsSync(dfpwmPath)) fs.unlinkSync(dfpwmPath); } else if (logicalPath.endsWith('.dfpwm')) { const wavPath = fullPath.replace(/\.dfpwm$/, '.wav'); if (fs.existsSync(wavPath)) fs.unlinkSync(wavPath); } res.json({ success: true }); } catch (e) { res.status(500).json({ success: false, message: e.message }); } } else { res.status(404).json({ success: false, message: 'File not found' }); } }); app.get('/api/stations', (req, res) => { const list = getMergedStationList() .sort((a, b) => String(a.code || '').localeCompare(String(b.code || '')) || String(a.name || '').localeCompare(String(b.name || ''))); res.json({ success: true, stations: list }); }); app.post('/api/stations', express.json(), (req, res) => { const name = String(req.body?.name || '').trim(); const code = req.body?.code === undefined ? '' : String(req.body.code).trim(); if (!name) return res.status(400).json({ success: false, message: 'Station name required' }); if (stations[name]) return res.status(409).json({ success: false, message: 'Station already exists' }); stations[name] = { name, code, platform: getPlatformNumberFromName(name), lastSeen: Date.now() }; normalizeStationRecord(stations[name]); saveData(); broadcastUpdate(); res.json({ success: true, station: { name, code, lastSeen: stations[name].lastSeen } }); }); app.put('/api/stations/:name', express.json(), (req, res) => { const name = req.params.name; const st = stations[name]; if (!st) return res.status(404).json({ success: false, message: 'Station not found' }); const newName = req.body?.newName === undefined ? '' : String(req.body.newName).trim(); const nextCode = req.body?.code === undefined ? undefined : String(req.body.code).trim(); let targetName = name; if (newName && newName !== name) { if (stations[newName]) return res.status(409).json({ success: false, message: 'New station name already exists' }); stations[newName] = { ...st, name: newName }; delete stations[name]; targetName = newName; } if (nextCode !== undefined) stations[targetName].code = nextCode; stations[targetName].platform = getPlatformNumberFromName(stations[targetName].name); stations[targetName].lastSeen = stations[targetName].lastSeen || Date.now(); normalizeStationRecord(stations[targetName]); saveData(); broadcastUpdate(); res.json({ success: true, station: { name: stations[targetName].name, code: stations[targetName].code, lastSeen: stations[targetName].lastSeen } }); }); app.delete('/api/stations/:name', (req, res) => { const name = req.params.name; if (stations[name]) { delete stations[name]; log('INFO', `Deleted station: ${name}`); saveData(); broadcastUpdate(); res.json({ success: true }); } else { res.status(404).json({ success: false, message: 'Station not found' }); } }); app.delete('/api/streams/:streamName', (req, res) => { const streamName = String(req.params.streamName || '').trim(); if (!streamName) return res.status(400).json({ success: false, message: 'Stream name required' }); const state = hlsStreams[streamName]; if (!state) return res.status(404).json({ success: false, message: 'Stream not found' }); if (state.currentProcess) { try { state.currentProcess.kill('SIGKILL'); } catch {} } delete hlsStreams[streamName]; res.json({ success: true }); }); app.delete('/api/trains/:id', (req, res) => { const id = req.params.id; const initialLength = activeTrains.length; activeTrains = activeTrains.filter(t => t.trainId !== id); if (activeTrains.length < initialLength) { log('INFO', `Deleted train: ${id}`); saveData(); broadcastUpdate(); res.json({ success: true }); } else { res.status(404).json({ success: false, message: 'Train not found' }); } }); app.get('/api/files/content', (req, res) => { const filePath = req.query.path; if (!filePath) return res.status(400).json({ success: false, message: 'Path required' }); const resolved = resolveManagedPath(filePath); if (!resolved) return res.status(403).json({ success: false, message: 'Invalid path' }); const { fullPath } = resolved; if (fs.existsSync(fullPath)) { try { const content = fs.readFileSync(fullPath, 'utf8'); res.json({ success: true, content }); } catch (e) { res.status(500).json({ success: false, message: e.message }); } } else { res.status(404).json({ success: false, message: 'File not found' }); } }); app.post('/api/files/content', express.json(), (req, res) => { const { path: filePath, content } = req.body; if (!filePath || content === undefined) return res.status(400).json({ success: false, message: 'Missing parameters' }); const resolved = resolveManagedPath(filePath); if (!resolved) return res.status(403).json({ success: false, message: 'Invalid path' }); const { fullPath, logicalPath } = resolved; try { fs.writeFileSync(fullPath, content, 'utf8'); log('INFO', `Updated file: ${logicalPath}`); res.json({ success: true }); } catch (e) { res.status(500).json({ success: false, message: e.message }); } }); // TTS Generation Proxy const tls = require('tls'); function requestViaProxy(urlStr, options, proxyUrlStr) { return new Promise((resolve, reject) => { const url = new URL(urlStr); const proxyUrl = new URL(proxyUrlStr); const proxyReq = http.request({ host: proxyUrl.hostname, port: proxyUrl.port, method: 'CONNECT', path: url.hostname + ':' + (url.port || 443), }); proxyReq.on('connect', (res, socket, head) => { if (res.statusCode !== 200) { return reject(new Error('Proxy connection failed: ' + res.statusCode)); } const tlsSocket = tls.connect({ socket: socket, servername: url.hostname }, () => { const req = require('https').request({ host: url.hostname, port: url.port || 443, path: url.pathname + url.search, method: options.method || 'GET', headers: options.headers, createConnection: () => tlsSocket }, (res) => { let body = []; res.on('data', chunk => body.push(chunk)); res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, buffer: Buffer.concat(body), text: Buffer.concat(body).toString('utf8') })); }); req.on('error', reject); if (options.body) req.write(options.body); req.end(); }); tlsSocket.on('error', reject); }); proxyReq.on('error', reject); proxyReq.end(); }); } function requestDirect(urlStr, options) { return new Promise((resolve, reject) => { const url = new URL(urlStr); const req = require('https').request({ host: url.hostname, port: url.port || 443, path: url.pathname + url.search, method: options.method || 'GET', headers: options.headers }, (res) => { let body = []; res.on('data', chunk => body.push(chunk)); res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, buffer: Buffer.concat(body), text: Buffer.concat(body).toString('utf8') })); }); req.on('error', reject); if (options.body) req.write(options.body); req.end(); }); } async function generateTtsWavToTemp(text, reference_id) { const proxyUrl = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy; const requestBody = JSON.stringify({ text: text, reference_id: reference_id, format: "wav" }); const options = { method: 'POST', headers: { 'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649', 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(requestBody) }, body: requestBody }; const response = proxyUrl ? await requestViaProxy('https://api.fish.audio/v1/tts', options, proxyUrl) : await requestDirect('https://api.fish.audio/v1/tts', options); if (!response.ok) { const msg = (response.text && String(response.text).trim().length > 0) ? response.text : `Upstream API error (HTTP ${response.status})`; const err = new Error(msg); err.status = response.status || 500; throw err; } const tempName = `temp_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`; const tempPath = path.join(uploadDir, tempName); fs.writeFileSync(tempPath, response.buffer); return { tempName, tempPath, url: `/uploads/${tempName}` }; } app.post('/api/tts/generate', express.json(), async (req, res) => { const { text, reference_id } = req.body; if (!text || !reference_id) return res.status(400).json({ success: false, message: 'Missing text or reference_id' }); try { const proxyUrl = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy; console.log(`[TTS] Sending request for text: "${text}"${proxyUrl ? ` via proxy: ${proxyUrl}` : ''}`); const requestBody = JSON.stringify({ text: text, reference_id: reference_id, format: "wav" }); const options = { method: 'POST', headers: { 'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649', 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(requestBody) }, body: requestBody }; const response = proxyUrl ? await requestViaProxy('https://api.fish.audio/v1/tts', options, proxyUrl) : await requestDirect('https://api.fish.audio/v1/tts', options); if (!response.ok) { const msg = (response.text && String(response.text).trim().length > 0) ? response.text : `Upstream API error (HTTP ${response.status})`; console.error(`[TTS] API Error Status ${response.status}: ${msg}`); return res.status(response.status || 500).json({ success: false, message: msg }); } // Save to a temporary file const tempName = `temp_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`; const tempPath = path.join(uploadDir, tempName); fs.writeFileSync(tempPath, response.buffer); console.log(`[TTS] Success: saved to ${tempName}`); res.json({ success: true, url: `/uploads/${tempName}`, tempName }); } catch (e) { console.error("[TTS] Generation Error:", e); const msg = (e && e.message && String(e.message).trim().length > 0) ? e.message : (e && e.code ? String(e.code) : 'Unknown error'); res.status(500).json({ success: false, message: msg + (e && e.cause && e.cause.message ? ` (${e.cause.message})` : '') }); } }); // Endpoint to receive temp files from client app.post('/api/tts/upload_temp', upload.fields([{ name: 'zhAudio', maxCount: 1 }, { name: 'enAudio', maxCount: 1 }]), (req, res) => { try { const zhFile = req.files['zhAudio'] ? req.files['zhAudio'][0] : null; const enFile = req.files['enAudio'] ? req.files['enAudio'][0] : null; if (!zhFile || !enFile) { return res.status(400).json({ success: false, message: 'Missing audio files' }); } // Rename to secure temp names const zhTempName = `temp_zh_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`; const enTempName = `temp_en_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`; const zhTempPath = path.join(uploadDir, zhTempName); const enTempPath = path.join(uploadDir, enTempName); fs.renameSync(zhFile.path, zhTempPath); fs.renameSync(enFile.path, enTempPath); res.json({ success: true, zhTempName, enTempName, zhUrl: `/uploads/${zhTempName}`, enUrl: `/uploads/${enTempName}` }); } catch (e) { res.status(500).json({ success: false, message: e.message }); } }); // Import TTS temp files to station folder app.post('/api/tts/import', express.json(), (req, res) => { const { stationCode, zhTemp, enTemp } = req.body; if (!stationCode || !zhTemp || !enTemp) return res.status(400).json({ success: false, message: 'Missing parameters' }); const zhSource = path.join(uploadDir, zhTemp); const enSource = path.join(uploadDir, enTemp); const safeStationCode = String(stationCode).replace(/\//g, '-'); const zhDest = path.join(audioStationDir, `ch-${safeStationCode}.wav`); const enDest = path.join(audioStationDir, `en-${safeStationCode}.wav`); try { if (fs.existsSync(zhSource)) { fs.copyFileSync(zhSource, zhDest); fs.unlinkSync(zhSource); } if (fs.existsSync(enSource)) { fs.copyFileSync(enSource, enDest); fs.unlinkSync(enSource); } log('SUCCESS', `Imported generated audio for station ${stationCode}`); res.json({ success: true }); } catch (e) { log('ERROR', `Failed to import audio: ${e.message}`); res.status(500).json({ success: false, message: e.message }); } }); function safeFilename(name) { const base = path.basename(String(name || '').trim()); if (!base || base.includes('/') || base.includes('\\')) return null; return base; } function findWavByBaseName(baseName) { const safeBase = String(baseName || '').trim().replace(/\.wav$/i, ''); if (!safeBase) return null; const candidates = [ path.join(audioGeneralDir, `${safeBase}.wav`), path.join(audioStationDir, `${safeBase}.wav`), path.join(uploadDir, `${safeBase}.wav`) ]; return candidates.find(p => fs.existsSync(p)) || null; } function buildTempBroadcastFromWav(prefixWavPath, contentWavPath) { const broadcastId = `${Date.now()}-${Math.random().toString(16).slice(2)}`; const outBase = `temp_${broadcastId}`; const outWavPath = path.join(tempBroadcastDir, `${outBase}.wav`); const filterComplex = [ '[0:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a0]', '[1:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a1]', '[a0][a1]concat=n=2:v=0:a=1[a]' ].join(';'); execSync(`ffmpeg -hide_banner -loglevel error -y -i "${prefixWavPath}" -i "${contentWavPath}" -filter_complex "${filterComplex}" -map "[a]" -ac 1 -ar 48000 -c:a pcm_s16le "${outWavPath}"`); const wavBuf = fs.readFileSync(outWavPath); const durationSec = getWavDurationSec(wavBuf) || 0; return { broadcastId, wavPath: outWavPath, durationSec }; } app.post('/api/temp-broadcast/build', (req, res) => { const contentTempName = safeFilename(req.body?.contentTempName); const prefixName = String(req.body?.prefixName || 'lsts').trim(); if (!contentTempName) return res.status(400).json({ success: false, message: 'Invalid contentTempName' }); const contentPath = path.join(uploadDir, contentTempName); if (!contentPath.startsWith(uploadDir)) return res.status(403).json({ success: false, message: 'Invalid path' }); if (!fs.existsSync(contentPath)) return res.status(404).json({ success: false, message: 'Content temp wav not found' }); const prefixWavPath = findWavByBaseName(prefixName); if (!prefixWavPath) return res.status(404).json({ success: false, message: `Prefix wav not found: ${prefixName}.wav` }); try { const built = buildTempBroadcastFromWav(prefixWavPath, contentPath); res.json({ success: true, broadcastId: built.broadcastId, wavUrl: `/uploads/temp-broadcast/${path.basename(built.wavPath)}`, durationSec: built.durationSec }); } catch (e) { res.status(500).json({ success: false, message: e?.message || String(e) }); } }); app.post('/api/temp-broadcast/compose', express.json(), async (req, res) => { const text = String(req.body?.text || '').trim(); const reference_id = String(req.body?.reference_id || DEFAULT_ZH_MODEL).trim(); const prefixName = String(req.body?.prefixName || 'lsts').trim(); if (!text) return res.status(400).json({ success: false, message: 'text required' }); if (!reference_id) return res.status(400).json({ success: false, message: 'reference_id required' }); const prefixWavPath = findWavByBaseName(prefixName); if (!prefixWavPath) return res.status(404).json({ success: false, message: `Prefix wav not found: ${prefixName}.wav` }); let ttsTempPath = null; try { const tts = await generateTtsWavToTemp(text, reference_id); ttsTempPath = tts.tempPath; const built = buildTempBroadcastFromWav(prefixWavPath, ttsTempPath); try { fs.unlinkSync(ttsTempPath); } catch {} ttsTempPath = null; res.json({ success: true, broadcastId: built.broadcastId, wavUrl: `/uploads/temp-broadcast/${path.basename(built.wavPath)}`, durationSec: built.durationSec }); } catch (e) { if (ttsTempPath) { try { fs.unlinkSync(ttsTempPath); } catch {} } res.status(Number(e?.status) || 500).json({ success: false, message: e?.message || String(e) }); } }); app.post('/api/temp-broadcast/publish', (req, res) => { const broadcastId = String(req.body?.broadcastId || '').trim(); const urgency = String(req.body?.urgency || 'normal').trim().toLowerCase(); const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : []; if (!broadcastId) return res.status(400).json({ success: false, message: 'broadcastId required' }); if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' }); if (urgency !== 'normal' && urgency !== 'emergency') return res.status(400).json({ success: false, message: 'Invalid urgency' }); const wavPath = path.join(tempBroadcastDir, `temp_${broadcastId}.wav`); if (!fs.existsSync(wavPath)) return res.status(404).json({ success: false, message: 'Temp broadcast not found (wav)' }); const wavBuf = fs.readFileSync(wavPath); const durationSec = getWavDurationSec(wavBuf) || 0; const streamUrl = getManagedMediaUrl('temp', path.basename(wavPath)); const priority = urgency === 'emergency' ? 2 : 0; const interruptMode = urgency === 'emergency' ? 'all' : 'none'; const source = urgency === 'emergency' ? 'temp_emergency' : 'temp_normal'; const requeueOnPreempt = urgency !== 'emergency'; let sent = 0; let failed = 0; const results = []; for (const rawName of stationsReq) { const name = String(rawName || '').trim(); if (!name) continue; const ws = getStationWs(name); if (!ws || ws.readyState !== WebSocket.OPEN) { failed++; results.push({ stationName: name, ok: false, reason: 'offline' }); continue; } enqueueStationAudioTask(name, ws, { audioFormat: 'stream', streamUrl, durationSec, priority, interruptMode, source, audioId: `temp-${broadcastId}-${name}-${Date.now()}`, requeueOnPreempt, consoleStreamStart: { type: 'stream_start', station: name, seq: [`TEMP:${broadcastId}`], streamName: getStationStreamName(name), hlsUrl: getStationHlsUrl(getStationStreamName(name)) }, consoleStreamEnd: { type: 'stream_end', station: name } }); sent++; results.push({ stationName: name, ok: true }); } res.json({ success: true, sent, failed, results }); }); app.get('/api/music/tracks', (req, res) => { res.json({ success: true, tracks: listMusicTracks() }); }); app.post('/api/music/start', express.json(), (req, res) => { const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : []; const track = normalizeBaseName(req.body?.track); const loop = Boolean(req.body?.loop); if (!track) return res.status(400).json({ success: false, message: 'track required' }); if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' }); let playbackAudio; try { playbackAudio = getPlaybackAudioByBaseName(track); } catch (e) { return res.status(404).json({ success: false, message: e?.message || String(e) }); } let sent = 0; let failed = 0; const results = []; for (const rawName of stationsReq) { const name = String(rawName || '').trim(); if (!name) continue; const ws = getStationWs(name); if (!ws || ws.readyState !== WebSocket.OPEN) { failed++; results.push({ stationName: name, ok: false, reason: 'offline' }); continue; } stopStationMusic(name, ws); stationMusicStates.set(name, { enabled: true, track, loop }); const enqueueOnce = () => { const currentWs = getStationWs(name); if (!currentWs || currentWs.readyState !== WebSocket.OPEN) { // If offline, retry later instead of giving up setTimeout(enqueueOnce, 5000); return; } enqueueStationAudioTask(name, currentWs, { audioFormat: playbackAudio.audioFormat, buffer: playbackAudio.buffer, streamUrl: playbackAudio.streamUrl, durationSec: playbackAudio.durationSec, priority: -1, interruptMode: 'none', source: 'music', audioId: `music-${track}-${name}-${Date.now()}`, requeueOnPreempt: true, consoleStreamStart: { type: 'stream_start', station: name, seq: [`MUSIC:${track}`], streamName: getStationStreamName(name), hlsUrl: getStationHlsUrl(getStationStreamName(name)) }, consoleStreamEnd: { type: 'stream_end', station: name }, onDone: (info) => { const st = stationMusicStates.get(name); if (!st || !st.enabled) return; if (st.track !== track) return; if (!st.loop) return; // Only stop if cancelled by something else (like stopMusic or higher priority) if (info && info.cancelled) { // If it was just preempted, enqueueStationAudioTask handles the re-insertion // If it was cancelled by stopStationMusic, st.enabled will be false return; } enqueueOnce(); } }); }; enqueueOnce(); sent++; results.push({ stationName: name, ok: true }); } res.json({ success: true, sent, failed, results }); }); app.post('/api/music/stop', express.json(), (req, res) => { const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : []; if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' }); let stopped = 0; const results = []; for (const rawName of stationsReq) { const name = String(rawName || '').trim(); if (!name) continue; const ws = getStationWs(name); stationMusicStates.set(name, { enabled: false, track: null, loop: false }); stopStationMusic(name, ws); stopped++; results.push({ stationName: name, ok: true }); } res.json({ success: true, stopped, results }); }); // Error handling middleware app.use((err, req, res, next) => { log('ERROR', `Unhandled error: ${err.message}`); res.status(err.status || 500).json({ success: false, message: err.message || 'Internal Server Error' }); }); server.listen(PORT, () => { loadData(); if (!audioVersion) audioVersion = String(Date.now()); if (!Array.isArray(versions) || versions.length === 0) { const snap = snapshotFiles(); const changes = diffSnapshots(null, snap); versions = [{ id: String(Date.now()), version: audioVersion, note: '', createdAt: Date.now(), snapshot: snap, changes }]; saveData(); } console.log(`Cloud Server running on port ${PORT}`); });