Files
CC-StationBroadcast/CloudServer/server.js
T
Henry_Du fd154afd82 fix(music): remove HLS fallback from station playback
Keep music playback on the direct PCM or DFPWM paths so music requests no longer trigger RTMP/HLS push attempts when a WAV source is unavailable.
2026-07-31 21:17:04 +08:00

2600 lines
92 KiB
JavaScript

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,
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 };
}
async function sendPcmStreamToStation(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('PCM stream sourcePath required');
if (!fs.existsSync(sourcePath)) throw new Error(`PCM 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;
targetWs.send(JSON.stringify({
type: 'audio_start',
stationName,
audioId,
byteLength: 0,
sampleRate: 48000,
channels: 1,
audioFormat: 'pcm',
pcmEncoding: 's8',
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', 's8', '-acodec', 'pcm_s8', '-ac', '1', '-ar', '48000', 'pipe:1');
log('INFO', `PCM music stream start for ${stationName}: ${path.basename(sourcePath)}${loop ? ' (loop)' : ''}`);
return await new Promise((resolve, reject) => {
let stderr = '';
let settled = false;
let proc = null;
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;
}
proc.stdout.on('data', (chunk) => {
if (!chunk || !chunk.length || settled) return;
if (token && token.cancelled) {
cancelStream();
return;
}
const ws = getStationWs(stationName, fallbackWs);
if (!ws || ws.readyState !== WebSocket.OPEN) {
cancelStream();
return;
}
try {
ws.send(chunk);
} catch (err) {
finish(null, err);
}
});
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;
log(cancelled ? 'INFO' : 'SUCCESS', `PCM music 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 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 {
sourcePath: p,
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 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 === 'pcm'
? await sendPcmStreamToStation(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 };
}
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 streamName = getStationStreamName(name);
const hlsUrl = HLS_OUTPUT_MODE === 'local'
? getLocalHlsUrlForRequest(streamName, req)
: getStationHlsUrlForRequest(streamName, req);
const enqueueOnce = () => {
const currentWs = getStationWs(name);
if (!currentWs || currentWs.readyState !== WebSocket.OPEN) {
// If offline, retry later instead of giving up
setTimeout(enqueueOnce, 5000);
return;
}
if (playbackAudio.sourcePath) {
enqueueStationAudioTask(name, currentWs, {
audioFormat: 'pcm',
sourcePath: playbackAudio.sourcePath,
durationSec: playbackAudio.durationSec,
priority: -1,
interruptMode: 'none',
source: 'music',
loop,
audioId: `music-${track}-${name}-${Date.now()}`,
requeueOnPreempt: true,
consoleStreamStart: { type: 'stream_start', station: name, seq: [`MUSIC:${track}`], streamName, hlsUrl },
consoleStreamEnd: { type: 'stream_end', station: name }
});
return;
}
enqueueStationAudioTask(name, currentWs, {
audioFormat: playbackAudio.audioFormat,
buffer: playbackAudio.buffer,
streamUrl: playbackAudio.streamUrl,
durationSec: playbackAudio.durationSec,
priority: -1,
interruptMode: 'none',
source: 'music',
loop,
audioId: `music-${track}-${name}-${Date.now()}`,
requeueOnPreempt: true,
consoleStreamStart: { type: 'stream_start', station: name, seq: [`MUSIC:${track}`], streamName, hlsUrl },
consoleStreamEnd: { type: 'stream_end', station: name }
});
};
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}`);
});