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_TTS_MODEL = process.env.TTS_MODEL || 's2.1-pro-free'; const DEFAULT_ZH_REFERENCE_ID = process.env.ZH_REFERENCE_ID || '0aee9f0f5683480d987c9e3c5d2de04c'; const DEFAULT_JA_REFERENCE_ID = process.env.JA_REFERENCE_ID || process.env.EN_REFERENCE_ID || '38985e545cff41b79c11c32b0196c9a0'; 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 audioMusicDir = path.join(audioRootDir, 'music'); const audioVoiceDir = path.join(audioRootDir, 'voice'); const audioLineDir = path.join(audioRootDir, 'line'); const audioBeepDir = path.join(audioRootDir, 'beep'); const audioGeneralLegacyDir = path.join(audioRootDir, 'general'); const audioStationDir = path.join(audioRootDir, 'station'); const listFilePath = path.join(appRootDir, 'list.txt'); const tempBroadcastDir = path.join(appRootDir, 'temp-broadcast'); const STREAMABLE_AUDIO_EXTENSIONS = ['.wav', '.mp3', '.ogg', '.flac', '.m4a', '.aac', '.opus']; const MUSIC_AUDIO_EXTENSIONS = [...STREAMABLE_AUDIO_EXTENSIONS, '.dfpwm']; if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true }); if (!fs.existsSync(audioRootDir)) fs.mkdirSync(audioRootDir, { recursive: true }); if (!fs.existsSync(audioMusicDir)) fs.mkdirSync(audioMusicDir, { recursive: true }); if (!fs.existsSync(audioVoiceDir)) fs.mkdirSync(audioVoiceDir, { recursive: true }); if (!fs.existsSync(audioLineDir)) fs.mkdirSync(audioLineDir, { recursive: true }); if (!fs.existsSync(audioBeepDir)) fs.mkdirSync(audioBeepDir, { recursive: true }); if (!fs.existsSync(audioStationDir)) fs.mkdirSync(audioStationDir, { recursive: true }); if (!fs.existsSync(tempBroadcastDir)) fs.mkdirSync(tempBroadcastDir, { recursive: true }); function getBeepAudioDirs() { const dirs = [audioBeepDir]; if (audioGeneralLegacyDir !== audioBeepDir && fs.existsSync(audioGeneralLegacyDir)) { dirs.push(audioGeneralLegacyDir); } return dirs; } function sendBinaryFileFromCandidates(res, candidates) { for (const filePath of candidates) { if (fs.existsSync(filePath)) { return res.sendFile(filePath); } } return res.status(404).send('Not found'); } // Multer storage configuration const storage = multer.diskStorage({ destination: function (req, file, cb) { if (file.fieldname === 'listFile') { cb(null, appRootDir); } else if (file.fieldname === 'musicAudioFiles') { cb(null, audioMusicDir); } else if (file.fieldname === 'voiceAudioFiles') { cb(null, audioVoiceDir); } else if (file.fieldname === 'lineAudioFiles') { cb(null, audioLineDir); } else if (file.fieldname === 'generalAudioFiles') { cb(null, audioBeepDir); } 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 || '')); sendBinaryFileFromCandidates(res, getBeepAudioDirs().map(dir => path.join(dir, name))); }); app.get('/media/audio/beep/:file', (req, res) => { const name = path.basename(String(req.params.file || '')); sendBinaryFileFromCandidates(res, getBeepAudioDirs().map(dir => path.join(dir, name))); }); app.get('/media/audio/music/:file', (req, res) => { const name = path.basename(String(req.params.file || '')); sendBinaryFile(res, path.join(audioMusicDir, name)); }); app.get('/media/audio/voice/:file', (req, res) => { const name = path.basename(String(req.params.file || '')); sendBinaryFile(res, path.join(audioVoiceDir, name)); }); app.get('/media/audio/line/:file', (req, res) => { const name = path.basename(String(req.params.file || '')); sendBinaryFile(res, path.join(audioLineDir, 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/music/:file', '/media/audio/voice/:file', '/media/audio/line/:file', '/media/audio/beep/:file', '/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 getStationGroupKey(station) { if (!station) return null; const code = normalizeStationCode(station.code || getStationCodeFromName(station.name)); const platform = normalizeStationPlatform(station.platform || getPlatformNumberFromName(station.name)); if (code) return `${code}@@${platform}`; return `name@@${String(station.name || '').trim()}`; } function getStationGroupTarget(station) { const groupKey = getStationGroupKey(station); return groupKey ? `group:${groupKey}` : null; } function getStationGroups() { const groups = new Map(); for (const k in stations) { if (!Object.prototype.hasOwnProperty.call(stations, k)) continue; const st = stations[k]; const code = normalizeStationCode(st.code || getStationCodeFromName(st.name)) || ''; const platform = normalizeStationPlatform(st.platform || getPlatformNumberFromName(st.name)); const key = getStationGroupKey(st); if (!key) continue; if (!groups.has(key)) { groups.set(key, { key, target: getStationGroupTarget(st), name: st.name, code, platform, lastSeen: st.lastSeen || 0, source: 'cc', tags: ['CC'], memberNames: [st.name], memberCount: 1, primaryName: st.name }); continue; } const group = groups.get(key); group.memberNames.push(st.name); group.memberCount = group.memberNames.length; if ((st.lastSeen || 0) > (group.lastSeen || 0)) { group.lastSeen = st.lastSeen || 0; } } return Array.from(groups.values()).map(group => ({ ...group, tags: group.memberCount > 1 ? ['CC', `GROUP:${group.memberCount}`] : ['CC'] })); } function resolveStationGroup(selector) { const clean = String(selector || '').trim(); if (!clean) return null; if (Object.prototype.hasOwnProperty.call(stations, clean)) { const st = stations[clean]; return { target: getStationGroupTarget(st) || clean, name: st.name, code: normalizeStationCode(st.code || getStationCodeFromName(st.name)) || '', platform: normalizeStationPlatform(st.platform || getPlatformNumberFromName(st.name)), memberNames: [st.name], memberCount: 1, primaryName: st.name }; } for (const group of getStationGroups()) { if (group.target === clean || group.name === clean) { return group; } } return null; } function expandStationTargets(selectors) { const groups = []; const seenTargets = new Set(); for (const raw of Array.isArray(selectors) ? selectors : []) { const group = resolveStationGroup(raw); if (!group) continue; const target = String(group.target || group.name || '').trim(); if (!target || seenTargets.has(target)) continue; seenTargets.add(target); groups.push(group); } return groups; } function getMergedStationList() { return getStationGroups(); } 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, loop: !!task.loop, 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 }; } function buildWavChunkFromPcm(buffer, sampleRate = 48000, channels = 1, bitsPerSample = 16) { const dataSize = buffer.length; const header = Buffer.alloc(44); const byteRate = sampleRate * channels * (bitsPerSample / 8); const blockAlign = channels * (bitsPerSample / 8); header.write('RIFF', 0, 4, 'ascii'); header.writeUInt32LE(36 + dataSize, 4); header.write('WAVE', 8, 4, 'ascii'); header.write('fmt ', 12, 4, 'ascii'); header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(channels, 22); header.writeUInt32LE(sampleRate, 24); header.writeUInt32LE(byteRate, 28); header.writeUInt16LE(blockAlign, 32); header.writeUInt16LE(bitsPerSample, 34); header.write('data', 36, 4, 'ascii'); header.writeUInt32LE(dataSize, 40); return Buffer.concat([header, buffer]); } async function sendLiveWavChunksToStation(stationName, fallbackWs, task) { const targetWs = getStationWs(stationName, fallbackWs); if (!targetWs) throw new Error(`Station "${stationName}" not connected`); const sourcePath = String(task.sourcePath || '').trim(); if (!sourcePath) throw new Error('Live WAV sourcePath required'); if (!fs.existsSync(sourcePath)) throw new Error(`Live WAV source missing: ${sourcePath}`); const audioId = String(task.audioId || `${Date.now()}-${Math.random().toString(16).slice(2)}`); const durationSec = Number(task.durationSec || 0); const loop = !!task.loop; const token = task.cancelToken; const sampleRate = 48000; const channels = 1; const bitsPerSample = 16; const chunkDurationSec = 0.25; const chunkBytes = Math.max(4096, Math.floor(sampleRate * channels * (bitsPerSample / 8) * chunkDurationSec)); targetWs.send(JSON.stringify({ type: 'audio_start', stationName, audioId, byteLength: 0, sampleRate, channels, audioFormat: 'wav-stream', loop, priority: Number(task.priority || 0), interruptMode: task.interruptMode || getInterruptModeForPriority(Number(task.priority || 0)), source: task.source || 'unknown' })); const ffmpegArgs = ['-hide_banner', '-loglevel', 'error']; if (loop) ffmpegArgs.push('-stream_loop', '-1'); ffmpegArgs.push('-re', '-i', sourcePath, '-f', 's16le', '-acodec', 'pcm_s16le', '-ac', String(channels), '-ar', String(sampleRate), 'pipe:1'); log('INFO', `Live WAV chunk stream start for ${stationName}: ${path.basename(sourcePath)}${loop ? ' (loop)' : ''} chunk=${chunkDurationSec}s`); return await new Promise((resolve, reject) => { let stderr = ''; let settled = false; let proc = null; let pcmPending = Buffer.alloc(0); const finish = (result, error) => { if (settled) return; settled = true; if (task.onCancel === cancelStream) task.onCancel = null; if (error) { reject(error); return; } try { const ws = getStationWs(stationName, fallbackWs); if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'audio_end', stationName, audioId, durationSec: result.durationSec })); } } catch {} resolve(result); }; const cancelStream = () => { if (token) token.cancelled = true; if (proc && !proc.killed) { try { proc.kill('SIGKILL'); } catch {} } }; task.onCancel = cancelStream; try { proc = spawn('ffmpeg', ffmpegArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); } catch (err) { finish(null, err); return; } const flushChunk = (pcmChunk) => { if (!pcmChunk || !pcmChunk.length) return; const ws = getStationWs(stationName, fallbackWs); if (!ws || ws.readyState !== WebSocket.OPEN) { cancelStream(); return; } try { ws.send(buildWavChunkFromPcm(pcmChunk, sampleRate, channels, bitsPerSample)); } catch (err) { finish(null, err); } }; proc.stdout.on('data', (chunk) => { if (!chunk || !chunk.length || settled) return; if (token && token.cancelled) { cancelStream(); return; } pcmPending = pcmPending.length > 0 ? Buffer.concat([pcmPending, chunk]) : Buffer.from(chunk); while (pcmPending.length >= chunkBytes && !settled) { const part = pcmPending.subarray(0, chunkBytes); pcmPending = pcmPending.subarray(chunkBytes); flushChunk(part); } }); proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); proc.on('error', (err) => { finish(null, err); }); proc.on('close', (code, signal) => { const cancelled = token && token.cancelled; if (!cancelled && pcmPending.length > 0) { flushChunk(pcmPending); pcmPending = Buffer.alloc(0); } log(cancelled ? 'INFO' : 'SUCCESS', `Live WAV chunk stream end for ${stationName}: ${path.basename(sourcePath)}${cancelled ? ' (cancelled)' : ''}`); if (!cancelled && code !== 0) { finish(null, new Error((stderr || `ffmpeg exited with code ${code ?? 'unknown'} signal ${signal ?? 'none'}`).trim())); return; } finish({ audioId, cancelled: !!cancelled, durationSec: loop ? 0 : durationSec }); }); }); } const stationMusicStates = new Map(); function hasSupportedAudioExtension(name, exts = MUSIC_AUDIO_EXTENSIONS) { const lower = String(name || '').trim().toLowerCase(); return exts.some(ext => lower.endsWith(ext)); } function stripSupportedAudioExtension(name, exts = MUSIC_AUDIO_EXTENSIONS) { const value = String(name || '').trim(); for (const ext of exts) { if (value.toLowerCase().endsWith(ext)) { return value.slice(0, -ext.length).trim(); } } return value; } function normalizeBaseName(name) { const s = stripSupportedAudioExtension(name); if (!s) return null; const base = s.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(); const scanDirs = [audioMusicDir]; if (fs.existsSync(audioMusicDir) && fs.readdirSync(audioMusicDir).length === 0) { scanDirs.push(...getBeepAudioDirs()); } for (const dir of scanDirs) { if (!fs.existsSync(dir)) continue; for (const file of fs.readdirSync(dir)) { if (!file || file.startsWith('.')) continue; const full = path.join(dir, file); if (!fs.existsSync(full) || !fs.statSync(full).isFile()) continue; if (!hasSupportedAudioExtension(file)) 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'); for (const ext of STREAMABLE_AUDIO_EXTENSIONS) { const candidates = [ path.join(audioMusicDir, `${base}${ext}`), ...getBeepAudioDirs().map(dir => path.join(dir, `${base}${ext}`)), path.join(audioStationDir, `${base}${ext}`) ]; for (const p of candidates) { if (!fs.existsSync(p)) continue; let durationSec = 0; if (ext === '.wav') { const buffer = fs.readFileSync(p); durationSec = getWavDurationSec(buffer) || 0; } return { sourcePath: p, audioFormat: 'stream', streamUrl: getManagedMediaUrl( p.startsWith(audioStationDir) ? 'audio/station' : (p.startsWith(audioMusicDir) ? 'audio/music' : 'audio/beep'), path.basename(p) ), durationSec }; } } for (const p of [ path.join(audioMusicDir, `${base}.dfpwm`), ...getBeepAudioDirs().map(dir => path.join(dir, `${base}.dfpwm`)), path.join(audioStationDir, `${base}.dfpwm`) ]) { if (!fs.existsSync(p)) continue; const buffer = fs.readFileSync(p); return { buffer, audioFormat: 'dfpwm', durationSec: buffer.length / 6000 }; } throw new Error(`Audio not found: ${base}`); } function cancelStationAudioTask(task) { if (!task) return; if (task.cancelToken) task.cancelToken.cancelled = true; if (typeof task.onCancel === 'function') { try { task.onCancel(); } catch {} } if (task.endTimer) { try { clearTimeout(task.endTimer); } catch {} task.endTimer = null; } } 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') { cancelStationAudioTask(q.current); } 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) { cancelStationAudioTask(cur); 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) { cancelStationAudioTask(cur); 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 = task.audioFormat === 'wav-stream-live' ? await sendLiveWavChunksToStation(cleanStation, fallbackWs, task) : 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 }; } if ( normalized === 'Audio/beep/line.txt' || normalized === 'uploads/audio/beep/line.txt' || normalized === 'Audio/general/line.txt' || normalized === 'uploads/audio/general/line.txt' ) { const fullPath = fs.existsSync(path.join(audioBeepDir, 'line.txt')) ? path.join(audioBeepDir, 'line.txt') : path.join(audioGeneralLegacyDir, 'line.txt'); return { logicalPath: 'Audio/beep/line.txt', fullPath }; } const prefixes = [ { prefix: 'Audio/music/', baseDir: audioMusicDir, logicalPrefix: 'Audio/music/' }, { prefix: 'Audio/voice/', baseDir: audioVoiceDir, logicalPrefix: 'Audio/voice/' }, { prefix: 'Audio/beep/', baseDir: audioBeepDir, logicalPrefix: 'Audio/beep/' }, { prefix: 'Audio/general/', baseDir: audioGeneralLegacyDir, logicalPrefix: 'Audio/beep/' }, { prefix: 'uploads/audio/music/', baseDir: audioMusicDir, logicalPrefix: 'Audio/music/' }, { prefix: 'uploads/audio/voice/', baseDir: audioVoiceDir, logicalPrefix: 'Audio/voice/' }, { prefix: 'uploads/audio/beep/', baseDir: audioBeepDir, logicalPrefix: 'Audio/beep/' }, { prefix: 'uploads/audio/general/', baseDir: audioGeneralLegacyDir, logicalPrefix: 'Audio/beep/' }, { 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(audioMusicDir)) { fs.readdirSync(audioMusicDir).forEach(file => { if (file.endsWith('.dfpwm')) { const wavName = file.replace(/\.dfpwm$/, '.wav'); if (fs.existsSync(path.join(audioMusicDir, wavName))) return; } const filePath = path.join(audioMusicDir, file); const stats = fs.statSync(filePath); files.push({ name: file, path: `Audio/music/${file}`, type: 'music', size: stats.size, mtime: stats.mtimeMs }); }); } if (fs.existsSync(audioVoiceDir)) { fs.readdirSync(audioVoiceDir).forEach(file => { if (file.endsWith('.dfpwm')) { const wavName = file.replace(/\.dfpwm$/, '.wav'); if (fs.existsSync(path.join(audioVoiceDir, wavName))) return; } const filePath = path.join(audioVoiceDir, file); const stats = fs.statSync(filePath); files.push({ name: file, path: `Audio/voice/${file}`, type: 'voice', size: stats.size, mtime: stats.mtimeMs }); }); } if (fs.existsSync(audioLineDir)) { fs.readdirSync(audioLineDir).forEach(file => { if (file.endsWith('.dfpwm')) { const wavName = file.replace(/\.dfpwm$/, '.wav'); if (fs.existsSync(path.join(audioLineDir, wavName))) return; } const filePath = path.join(audioLineDir, file); const stats = fs.statSync(filePath); files.push({ name: file, path: `Audio/line/${file}`, type: 'line', size: stats.size, mtime: stats.mtimeMs }); }); } const beepSeen = new Set(); for (const dir of getBeepAudioDirs()) { if (!fs.existsSync(dir)) continue; fs.readdirSync(dir).forEach(file => { if (beepSeen.has(file)) return; beepSeen.add(file); if (file.endsWith('.dfpwm')) { const wavName = file.replace(/\.dfpwm$/, '.wav'); if (beepSeen.has(wavName) || fs.existsSync(path.join(dir, wavName))) return; } const filePath = path.join(dir, file); const stats = fs.statSync(filePath); files.push({ name: file, path: `Audio/beep/${file}`, type: file === 'line.txt' ? 'config' : 'beep', 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 findFileCaseInsensitive(dir, filename) { if (!fs.existsSync(dir)) return null; const files = fs.readdirSync(dir); const lowerFilename = filename.toLowerCase(); const match = files.find(f => f.toLowerCase() === lowerFilename); return match ? path.join(dir, match) : null; } function resolveSequenceFiles(seq) { ensureSilenceWav(); const files = []; for (const item of seq) { if (item === '__DELAY_1__') { files.push(silence1sWavPath); continue; } const dirsToSearch = [ audioStationDir, audioLineDir, audioVoiceDir, ...getBeepAudioDirs() ]; let found = null; for (const dir of dirsToSearch) { found = findFileCaseInsensitive(dir, `${item}.wav`); if (found) break; } 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); } return files; } function buildSequenceWavFile(seq) { const files = resolveSequenceFiles(seq); if (files.length === 0) return null; const fileName = `sequence-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`; const outWavPath = path.join(tempBroadcastDir, fileName); const inputs = files.map(f => `-i "${f}"`).join(' '); const filterResamples = files.map((_, i) => `[${i}:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a${i}]`).join(';'); const filterConcat = files.map((_, i) => `[a${i}]`).join('') + `concat=n=${files.length}:v=0:a=1[a]`; const filterComplex = `${filterResamples};${filterConcat}`; try { execSync(`ffmpeg -hide_banner -loglevel error -y ${inputs} -filter_complex "${filterComplex}" -map "[a]" -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: 'wav-stream-live', sourcePath: outWavPath, streamUrl: getManagedMediaUrl('temp', fileName), durationSec }; } catch (e) { log('ERROR', `buildSequenceWavFile failed: ${e.message}`); return null; } } function runFfmpegRtmpPush(streamName, seq) { const state = getOrCreateHlsStreamState(streamName); const files = resolveSequenceFiles(seq); if (files.length === 0) { 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 inputs = files.map(f => `-i "${f}"`).join(' '); const filterResamples = files.map((_, i) => `[${i}:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a${i}]`).join(';'); const filterConcat = files.map((_, i) => `[a${i}]`).join('') + `concat=n=${files.length}:v=0:a=1[a]`; const filterComplex = `${filterResamples};${filterConcat}`; const buildArgs = [ '-hide_banner', '-loglevel', 'error', '-y' ]; files.forEach(f => { buildArgs.push('-i', f); }); buildArgs.push( '-filter_complex', filterComplex, '-map', '[a]', '-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(tmpAacPath); } catch {} resolve(); }); }); }); } const filterResamples = files.map((_, i) => `[${i}:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a${i}]`).join(';'); const filterConcat = files.map((_, i) => `[a${i}]`).join('') + `concat=n=${files.length}:v=0:a=1[a]`; const filterComplex = `${filterResamples};${filterConcat}`; const args = [ '-hide_banner', '-loglevel', 'error', '-re' ]; files.forEach(f => { args.push('-i', f); }); args.push( '-filter_complex', filterComplex, '-map', '[a]', '-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(ws, data); break; case 'train_approaching': handleTrainApproaching(ws, data); break; case 'train_departure': handleTrainDeparture(ws, data); break; case 'play_sequence': break; case 'request_station_time': handleStationTimeRequest(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 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), version: data.stationVersion || '', lastSeen: Date.now(), ws: ws }; normalizeStationRecord(stations[cleanName]); log('INFO', `Station auto-registered via heartbeat: "${cleanName}"${data.stationVersion ? ` ${data.stationVersion}` : ''}`); broadcastUpdate(); } else { stations[cleanName].lastSeen = Date.now(); if (data.stationCode) stations[cleanName].code = data.stationCode; if (data.stationPlatform) stations[cleanName].platform = data.stationPlatform; if (data.stationVersion) stations[cleanName].version = data.stationVersion; if (stations[cleanName].ws !== ws) { stations[cleanName].ws = ws; // Update WS in case of reconnect log('INFO', `Station WebSocket updated via heartbeat: "${cleanName}"${data.stationVersion ? ` ${data.stationVersion}` : ''}`); broadcastUpdate(); } normalizeStationRecord(stations[cleanName]); } } } function handleStationRegister(ws, data) { const { stationName, stationCode, stationPlatform, stationVersion } = data; const cleanName = stationName.trim(); stations[cleanName] = { name: cleanName, code: stationCode, platform: stationPlatform || getPlatformNumberFromName(cleanName), version: stationVersion || '', lastSeen: Date.now(), ws: ws // Keep reference if we need to send back }; normalizeStationRecord(stations[cleanName]); log('SUCCESS', `Station registered: "${cleanName}" (${stationCode})${stationVersion ? ` ${stationVersion}` : ''}`); // Send ack ws.send(JSON.stringify({ type: 'station_register_ack', stationName: cleanName })); saveData(); 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 handleTrainApproaching(ws, data) { const trainId = data.trainId; const stationName = getCorrectStationName(data.stationName); const stationCode = data.stationCode; const platform = data.stationPlatform || data.platform || '1'; const cleanPlatform = String(platform).replace(/\D/g, ''); // 提取数字部分 const finalPlatform = cleanPlatform || '1'; const destCode = data.destCode || getStationCodeFromName(data.destination) || 'UNK'; log('INFO', `Train ${trainId} approaching ${stationName}. Broadcasting pre-arrival...`); ws.send(JSON.stringify({ type: 'train_approaching_ack', success: true, message: 'Train approaching processed successfully', trainId })); // Pre-arrival Audio Logic // 提示音:ksts // 日文:ja-fc1 + ja-站台编号 + ja-fc2 + ja-终点编号 + ja-fc3 // 中文:ch-fc1 + ch-fc2 + ch-fc3 + ch-终点编号 + ch-fc4 const seq = [ 'ksts', 'ja-fc1', `ja-${finalPlatform}`, 'ja-fc2', `ja-${destCode}`, 'ja-fc3', 'ch-fc1', 'ch-fc2', 'ch-fc3', `ch-${destCode}`, 'ch-fc4' ]; enqueueHlsPush(stationName, seq); const audioId = `approaching-${stationCode}-${Date.now()}`; const built = buildSequenceWavFile(seq); if (built) { enqueueStationAudioTask(stationName, ws, { audioFormat: built.audioFormat, sourcePath: built.sourcePath, streamUrl: built.streamUrl, durationSec: built.durationSec, priority: 1, interruptMode: 'ifLower', source: 'train_approaching', audioId, consoleStreamStart: { type: 'stream_start', station: stationName, seq, streamName: getStationStreamName(stationName), hlsUrl: getStationHlsUrl(getStationStreamName(stationName)) }, consoleStreamEnd: { type: 'stream_end', station: stationName } }); } } function handleTrainArrival(ws, data) { const trainId = data.trainId; const stationName = getCorrectStationName(data.stationName); const stationCode = data.stationCode; const platform = data.stationPlatform || data.platform || '1'; const cleanPlatform = String(platform).replace(/\D/g, ''); // 提取数字部分 const finalPlatform = cleanPlatform || '1'; const lineCode = data.lineCode || (stationCode ? stationCode.split('-')[0] : 'UNK'); const destCode = data.destCode || getStationCodeFromName(data.destination) || 'UNK'; const nextCode = data.nextCode || getStationCodeFromName(data.nextStation) || 'UNK'; const existingIndex = activeTrains.findIndex(t => t.trainId === trainId); const trainData = { trainId, stationName, status: 'arrived', lastUpdated: Date.now() }; if (existingIndex >= 0) { activeTrains[existingIndex] = trainData; } else { activeTrains.push(trainData); } log('INFO', `Train ${trainId} arrived at ${stationName}. Broadcasting...`); saveData(); broadcastUpdate(); ws.send(JSON.stringify({ type: 'train_arrival_ack', success: true, message: 'Train arrival processed successfully', trainId })); // Broadcast Audio Logic const seq = [ 'ksts', `ja-${finalPlatform}`, 'ja-zt', `ja-${lineCode}`, `ja-${destCode}`, 'ja-to', 'ja-nextsta', `ja-${nextCode}`, 'ja-warn', `ch-${finalPlatform}`, 'ch-zt', `ch-${lineCode}`, 'ch-to', `ch-${destCode}`, 'ch-nextsta', `ch-${nextCode}`, 'ch-warn' ]; enqueueHlsPush(stationName, seq); const audioId = `arrival-${stationCode}-${Date.now()}`; const built = buildSequenceWavFile(seq); if (built) { enqueueStationAudioTask(stationName, ws, { audioFormat: built.audioFormat, sourcePath: built.sourcePath, streamUrl: built.streamUrl, durationSec: built.durationSec, priority: 1, interruptMode: 'ifLower', source: 'train_arrival', audioId, consoleStreamStart: { type: 'stream_start', station: stationName, seq, streamName: getStationStreamName(stationName), hlsUrl: getStationHlsUrl(getStationStreamName(stationName)) }, consoleStreamEnd: { type: 'stream_end', station: stationName } }); } } function handleTrainDeparture(ws, data) { const trainId = data.trainId; const stationName = getCorrectStationName(data.stationName); const stationCode = data.stationCode; const beep = data.departureBeep || data.beep || 'fc1'; const existingIndex = activeTrains.findIndex(t => t.trainId === trainId); if (existingIndex >= 0) { activeTrains.splice(existingIndex, 1); } log('INFO', `Train ${trainId} departing from ${stationName}. Broadcasting departure...`); saveData(); broadcastUpdate(); ws.send(JSON.stringify({ type: 'train_departure_ack', success: true, message: 'Train departure processed successfully', trainId })); // Departure Audio Logic const seq = [ beep, 'ja-doorwarn', 'ch-doorwarn' ]; enqueueHlsPush(stationName, seq); const audioId = `departure-${stationCode}-${Date.now()}`; const built = buildSequenceWavFile(seq); if (built) { enqueueStationAudioTask(stationName, ws, { audioFormat: built.audioFormat, sourcePath: built.sourcePath, streamUrl: built.streamUrl, durationSec: built.durationSec, priority: 1, interruptMode: 'ifLower', source: 'train_departure', audioId, consoleStreamStart: { type: 'stream_start', station: stationName, seq, streamName: getStationStreamName(stationName), hlsUrl: getStationHlsUrl(getStationStreamName(stationName)) }, consoleStreamEnd: { type: 'stream_end', station: stationName } }); } } 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} beep audio files`); res.json({ success: true, message: `${files.length} beep audio files uploaded` }); }); app.post('/api/upload/music-audio', upload.array('musicAudioFiles'), (req, res) => { const files = req.files || []; log('SUCCESS', `Uploaded ${files.length} music audio files`); res.json({ success: true, message: `${files.length} music audio files uploaded` }); }); app.post('/api/upload/voice-audio', upload.array('voiceAudioFiles'), (req, res) => { const files = req.files || []; log('SUCCESS', `Uploaded ${files.length} voice audio files`); res.json({ success: true, message: `${files.length} voice audio files uploaded` }); }); app.post('/api/upload/line-audio', upload.array('lineAudioFiles'), (req, res) => { const files = req.files || []; log('SUCCESS', `Uploaded ${files.length} line audio files`); res.json({ success: true, message: `${files.length} line 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 selector = String(req.params.name || '').trim(); const group = resolveStationGroup(selector); if (group && Array.isArray(group.memberNames) && group.memberNames.length > 0) { for (const memberName of group.memberNames) { delete stations[memberName]; } log('INFO', `Deleted station target: ${selector} (${group.memberNames.length} node(s))`); 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, model = DEFAULT_TTS_MODEL) { let 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; if (!proxyUrl) { proxyUrl = 'http://127.0.0.1:7890'; } const requestBody = JSON.stringify({ text: text, reference_id: reference_id, model: model, 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/upload_temp', upload.single('audio'), (req, res) => { try { if (!req.file) return res.status(400).json({ success: false, message: 'No audio file uploaded' }); const tempName = `temp_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`; const tempPath = path.join(uploadDir, tempName); fs.renameSync(req.file.path, tempPath); res.json({ success: true, tempName: tempName, url: `/uploads/${tempName}` }); } catch (e) { res.status(500).json({ success: false, message: e.message }); } }); app.post('/api/tts/generate', express.json(), async (req, res) => { const text = String(req.body?.text || '').trim(); const reference_id = String(req.body?.reference_id || '').trim(); const model = String(req.body?.model || DEFAULT_TTS_MODEL).trim(); if (!text || !reference_id) return res.status(400).json({ success: false, message: 'Missing text or reference_id' }); if (!model) return res.status(400).json({ success: false, message: 'Missing model' }); try { let 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}" with model "${model}"${proxyUrl ? ` via proxy: ${proxyUrl}` : ''}`); const requestBody = JSON.stringify({ text: text, reference_id: reference_id, model: model, format: "wav" }); const options = { method: 'POST', headers: { 'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649', 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(requestBody) }, body: requestBody }; // Always try to use proxy if (!proxyUrl) { proxyUrl = 'http://127.0.0.1:7890'; } let response; try { response = proxyUrl ? await requestViaProxy('https://api.fish.audio/v1/tts', options, proxyUrl) : await requestDirect('https://api.fish.audio/v1/tts', options); } catch (e) { // Fallback if proxy fails if (proxyUrl === 'http://127.0.0.1:7890') { console.log(`[TTS] Local proxy failed, attempting direct connection...`); response = await requestDirect('https://api.fish.audio/v1/tts', options); } else { throw e; } } 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 jaTempName = `temp_ja_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`; const zhTempPath = path.join(uploadDir, zhTempName); const jaTempPath = path.join(uploadDir, jaTempName); fs.renameSync(zhFile.path, zhTempPath); fs.renameSync(enFile.path, jaTempPath); res.json({ success: true, zhTempName, jaTempName, zhUrl: `/uploads/${zhTempName}`, jaUrl: `/uploads/${jaTempName}`, // Keep old keys temporarily so cached frontends do not break. enTempName: jaTempName, enUrl: `/uploads/${jaTempName}` }); } 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 = String(req.body?.stationCode || '').trim(); const type = String(req.body?.type || 'station').trim(); const zhTemp = String(req.body?.zhTemp || '').trim(); const jaTemp = String(req.body?.jaTemp || req.body?.enTemp || '').trim(); if (!stationCode || !zhTemp || !jaTemp) return res.status(400).json({ success: false, message: 'Missing parameters' }); const zhSource = path.join(uploadDir, zhTemp); const jaSource = path.join(uploadDir, jaTemp); const safeStationCode = String(stationCode).replace(/\//g, '-'); const targetDir = type === 'line' ? audioLineDir : audioStationDir; const zhDest = path.join(targetDir, `ch-${safeStationCode}.wav`); const jaDest = path.join(targetDir, `ja-${safeStationCode}.wav`); try { if (fs.existsSync(zhSource)) { fs.copyFileSync(zhSource, zhDest); fs.unlinkSync(zhSource); } if (fs.existsSync(jaSource)) { fs.copyFileSync(jaSource, jaDest); fs.unlinkSync(jaSource); } 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 = [ ...getBeepAudioDirs().map(dir => path.join(dir, `${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: `/media/temp/${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_REFERENCE_ID).trim(); const model = String(req.body?.model || DEFAULT_TTS_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' }); if (!model) return res.status(400).json({ success: false, message: 'model 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, model); ttsTempPath = tts.tempPath; const built = buildTempBroadcastFromWav(prefixWavPath, ttsTempPath); try { fs.unlinkSync(ttsTempPath); } catch {} ttsTempPath = null; res.json({ success: true, broadcastId: built.broadcastId, wavUrl: `/media/temp/${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 group of expandStationTargets(stationsReq)) { const onlineMembers = group.memberNames.filter(memberName => { const ws = getStationWs(memberName); return !!ws && ws.readyState === WebSocket.OPEN; }); if (onlineMembers.length === 0) { failed++; results.push({ stationName: group.name, ok: false, reason: 'offline', memberCount: group.memberCount }); continue; } for (let idx = 0; idx < onlineMembers.length; idx++) { const memberName = onlineMembers[idx]; const ws = getStationWs(memberName); enqueueStationAudioTask(memberName, ws, { audioFormat: 'wav-stream-live', sourcePath: wavPath, streamUrl, durationSec, priority, interruptMode, source, audioId: `temp-${broadcastId}-${memberName}-${Date.now()}`, requeueOnPreempt, consoleStreamStart: idx === 0 ? { type: 'stream_start', station: group.name, seq: [`TEMP:${broadcastId}`], streamName: getStationStreamName(memberName), hlsUrl: getStationHlsUrl(getStationStreamName(memberName)) } : null, consoleStreamEnd: idx === 0 ? { type: 'stream_end', station: group.name } : null }); } sent++; results.push({ stationName: group.name, ok: true, memberCount: onlineMembers.length, totalMembers: group.memberCount }); } 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 group of expandStationTargets(stationsReq)) { const onlineMembers = group.memberNames.filter(memberName => { const ws = getStationWs(memberName); return !!ws && ws.readyState === WebSocket.OPEN; }); if (onlineMembers.length === 0) { failed++; results.push({ stationName: group.name, ok: false, reason: 'offline', memberCount: group.memberCount }); continue; } for (let idx = 0; idx < onlineMembers.length; idx++) { const memberName = onlineMembers[idx]; const ws = getStationWs(memberName); stopStationMusic(memberName, ws); stationMusicStates.set(memberName, { enabled: true, track, loop }); const streamName = getStationStreamName(memberName); const hlsUrl = HLS_OUTPUT_MODE === 'local' ? getLocalHlsUrlForRequest(streamName, req) : getStationHlsUrlForRequest(streamName, req); const enqueueOnce = () => { const currentWs = getStationWs(memberName); if (!currentWs || currentWs.readyState !== WebSocket.OPEN) { setTimeout(enqueueOnce, 5000); return; } if (playbackAudio.sourcePath) { enqueueStationAudioTask(memberName, currentWs, { audioFormat: 'wav-stream-live', sourcePath: playbackAudio.sourcePath, durationSec: playbackAudio.durationSec, priority: -1, interruptMode: 'none', source: 'music', loop, audioId: `music-${track}-${memberName}-${Date.now()}`, requeueOnPreempt: true, consoleStreamStart: idx === 0 ? { type: 'stream_start', station: group.name, seq: [`MUSIC:${track}`], streamName, hlsUrl } : null, consoleStreamEnd: idx === 0 ? { type: 'stream_end', station: group.name } : null }); return; } enqueueStationAudioTask(memberName, currentWs, { audioFormat: playbackAudio.audioFormat, buffer: playbackAudio.buffer, streamUrl: playbackAudio.streamUrl, durationSec: playbackAudio.durationSec, priority: -1, interruptMode: 'none', source: 'music', loop, audioId: `music-${track}-${memberName}-${Date.now()}`, requeueOnPreempt: true, consoleStreamStart: idx === 0 ? { type: 'stream_start', station: group.name, seq: [`MUSIC:${track}`], streamName, hlsUrl } : null, consoleStreamEnd: idx === 0 ? { type: 'stream_end', station: group.name } : null }); }; enqueueOnce(); } sent++; results.push({ stationName: group.name, ok: true, memberCount: onlineMembers.length, totalMembers: group.memberCount }); } 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 group of expandStationTargets(stationsReq)) { for (const memberName of group.memberNames) { const ws = getStationWs(memberName); stationMusicStates.set(memberName, { enabled: false, track: null, loop: false }); stopStationMusic(memberName, ws); } stopped++; results.push({ stationName: group.name, ok: true, memberCount: group.memberCount }); } 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}`); });