Files
CC-StationBroadcast/CloudServer/server.js
T
Henry_Du 87e45de15b fix(music): add wav chunk diagnostics and larger chunks
Increase live WAV chunk size to one second and log chunk acceptance, queue depth, and retry state on stations so the HQ speaker path can be diagnosed from runtime logs.
2026-07-31 22:46:47 +08:00

2514 lines
88 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 };
}
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 = 1.0;
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 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 === '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 };
}
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':
case 'train_departure':
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),
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 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 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: 'wav-stream-live',
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}`);
});