Files
CC-StationBroadcast/CloudServer/server.js
T
Henry_Du a6d9b6c418 fix(server): 修复 serve-static 不接受数组 root、改为按请求逐个挂载,避免启动时 TypeError: root path must be a string
上一轮把 getBeepAudioDirs() 这个数组直接塞进 express.static(),
但 serve-static 签名要求 root 必须是字符串,会在 app 启动注册
中间件时直接抛错(甚至在接收请求前)。

修复方式:
- /media/audio/general 和 /media/audio/beep 改为动态中间件:
  请求进来时再依次对每个目录调用一次 express.static(root, {fallthrough})
- 单目录的 music/voice/line/station/temp 保留 express.static
  但统一包一层 String(xxx || '') 保证就算路径解析异常也是空
  字符串而不是 undefined
- 后备的 7 条 app.get(':path(*)') 路由不变,仍然负责 sendBinaryFile /
  sendBinaryFileFromCandidates 兜底(含 range、Content-Length 等头)

客户端 bump v1.0.102 -> v1.0.103
2026-08-17 15:44:38 +08:00

4247 lines
158 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_TTS_MODEL = process.env.TTS_MODEL || 's2.1-pro-free';
const DEFAULT_ZH_REFERENCE_ID = process.env.ZH_REFERENCE_ID || '0aee9f0f5683480d987c9e3c5d2de04c';
const DEFAULT_JA_REFERENCE_ID = process.env.JA_REFERENCE_ID || process.env.EN_REFERENCE_ID || '38985e545cff41b79c11c32b0196c9a0';
const HLS_ENABLE = process.env.HLS_ENABLE !== '0';
const HLS_STREAM_MODE = (process.env.HLS_STREAM_MODE || 'event').toLowerCase();
const HLS_OUTPUT_MODE = (process.env.HLS_OUTPUT_MODE || 'srs').toLowerCase();
const SRS_RTMP_BASE = process.env.SRS_RTMP_BASE || 'rtmp://127.0.0.1/live';
const STABROADCAST_PUBLIC_BASE_DEFAULT = 'https://stabroadcast.fse-media.group';
const STABROADCAST_PUBLIC_BASE = process.env.STABROADCAST_PUBLIC_BASE || STABROADCAST_PUBLIC_BASE_DEFAULT;
const STABROADCAST_API_BASE = process.env.STABROADCAST_API_BASE || 'http://stabadmin.fse-media.group';
const STABROADCAST_HLS_SUFFIX = process.env.STABROADCAST_HLS_SUFFIX || '.m3u8';
const HLS_TAIL_SILENCE_SEC = Number(process.env.HLS_TAIL_SILENCE_SEC || '0');
const HLS_PREBUILD = process.env.HLS_PREBUILD !== '0';
const appRootDir = path.resolve(__dirname, '..');
const tmpDir = path.join(__dirname, 'tmp');
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
const silence1sWavPath = path.join(tmpDir, 'silence-1s.wav');
const localHlsDir = path.join(__dirname, 'public', 'hls');
if (!fs.existsSync(localHlsDir)) fs.mkdirSync(localHlsDir, { recursive: true });
const uploadDir = path.join(__dirname, 'public', 'uploads');
const audioRootDir = path.join(appRootDir, 'Audio');
const audioMusicDir = path.join(audioRootDir, 'music');
const audioVoiceDir = path.join(audioRootDir, 'voice');
const audioLineDir = path.join(audioRootDir, 'line');
const audioBeepDir = path.join(audioRootDir, 'beep');
const audioGeneralLegacyDir = path.join(audioRootDir, 'general');
const audioStationDir = path.join(audioRootDir, 'station');
const listFilePath = path.join(appRootDir, 'list.txt');
const tempBroadcastDir = path.join(appRootDir, 'temp-broadcast');
const stationScriptPath = path.join(appRootDir, 'station_broadcast.lua');
const stationUpdateScriptPath = path.join(appRootDir, 'update_station_broadcast.lua');
const STATION_SCRIPT_DOWNLOAD_PATH = '/downloads/station_broadcast.lua';
const STATION_UPDATE_SCRIPT_DOWNLOAD_PATH = '/downloads/update_station_broadcast.lua';
const STREAMABLE_AUDIO_EXTENSIONS = ['.wav', '.mp3', '.ogg', '.flac', '.m4a', '.aac', '.opus'];
const MUSIC_AUDIO_EXTENSIONS = [...STREAMABLE_AUDIO_EXTENSIONS, '.dfpwm'];
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
if (!fs.existsSync(audioRootDir)) fs.mkdirSync(audioRootDir, { recursive: true });
if (!fs.existsSync(audioMusicDir)) fs.mkdirSync(audioMusicDir, { recursive: true });
if (!fs.existsSync(audioVoiceDir)) fs.mkdirSync(audioVoiceDir, { recursive: true });
if (!fs.existsSync(audioLineDir)) fs.mkdirSync(audioLineDir, { recursive: true });
if (!fs.existsSync(audioBeepDir)) fs.mkdirSync(audioBeepDir, { recursive: true });
if (!fs.existsSync(audioStationDir)) fs.mkdirSync(audioStationDir, { recursive: true });
if (!fs.existsSync(tempBroadcastDir)) fs.mkdirSync(tempBroadcastDir, { recursive: true });
function getBeepAudioDirs() {
const dirs = [audioBeepDir];
if (audioGeneralLegacyDir !== audioBeepDir && fs.existsSync(audioGeneralLegacyDir)) {
dirs.push(audioGeneralLegacyDir);
}
return dirs;
}
function sendBinaryFileFromCandidates(res, candidates) {
for (const filePath of candidates) {
if (fs.existsSync(filePath)) {
return res.sendFile(filePath);
}
}
return res.status(404).send('Not found');
}
// Multer storage configuration
const storage = multer.diskStorage({
destination: function (req, file, cb) {
if (file.fieldname === 'listFile') {
cb(null, appRootDir);
} else if (file.fieldname === 'musicAudioFiles') {
cb(null, audioMusicDir);
} else if (file.fieldname === 'voiceAudioFiles') {
cb(null, audioVoiceDir);
} else if (file.fieldname === 'lineAudioFiles') {
cb(null, audioLineDir);
} else if (file.fieldname === 'generalAudioFiles') {
cb(null, audioBeepDir);
} else if (file.fieldname === 'stationAudioFiles') {
cb(null, audioStationDir);
} else {
cb(null, uploadDir);
}
},
filename: function (req, file, cb) {
if (file.fieldname === 'listFile') {
cb(null, 'list.txt');
} else {
// Use original name, handle encoding if needed (multer handles basic utf8)
file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8');
cb(null, file.originalname);
}
}
});
const upload = multer({ storage: storage });
// Middleware
app.use(bodyParser.json());
app.use((req, res, next) => {
if (req.path.endsWith('.m3u8')) res.type('application/vnd.apple.mpegurl');
else if (req.path.endsWith('.ts')) res.type('video/mp2t');
next();
});
function sendListFile(res) {
if (!fs.existsSync(listFilePath)) {
return res.status(404).send('list.txt not found');
}
res.type('text/plain; charset=utf-8');
res.send(fs.readFileSync(listFilePath, 'utf8'));
}
app.get('/list.txt', (req, res) => sendListFile(res));
app.get('/uploads/list.txt', (req, res) => sendListFile(res));
function sendBinaryFile(res, filePath) {
if (!fs.existsSync(filePath)) return res.status(404).send('Not found');
res.sendFile(filePath);
}
function safeRelativeToBase(baseDir, relativePath) {
const clean = String(relativePath || '').replace(/\.\./g, '').replace(/\\/g, '/').replace(/^\/+/, '');
if (!clean) return null;
const parts = clean.split('/').filter(Boolean);
if (parts.length === 0) return null;
const full = path.join(baseDir, ...parts);
if (!full.startsWith(baseDir)) return null;
return full;
}
app.use('/media/audio/general', (req, res, next) => {
const dirs = getBeepAudioDirs();
if (!dirs || dirs.length === 0) return next();
let i = 0;
const tryNext = () => {
if (i >= dirs.length) return next();
const dir = String(dirs[i++] || '');
if (!dir) return tryNext();
express.static(dir, { fallthrough: true })(req, res, tryNext);
};
tryNext();
});
app.use('/media/audio/beep', (req, res, next) => {
const dirs = getBeepAudioDirs();
if (!dirs || dirs.length === 0) return next();
let i = 0;
const tryNext = () => {
if (i >= dirs.length) return next();
const dir = String(dirs[i++] || '');
if (!dir) return tryNext();
express.static(dir, { fallthrough: true })(req, res, tryNext);
};
tryNext();
});
app.use('/media/audio/music', express.static(String(audioMusicDir || ''), { fallthrough: true }));
app.use('/media/audio/voice', express.static(String(audioVoiceDir || ''), { fallthrough: true }));
app.use('/media/audio/line', express.static(String(audioLineDir || ''), { fallthrough: true }));
app.use('/media/audio/station', express.static(String(audioStationDir || ''), { fallthrough: true }));
app.use('/media/temp', express.static(String(tempBroadcastDir || ''), { fallthrough: true }));
app.get('/media/audio/general/:path(*)', (req, res) => {
const rel = decodeURIComponent(req.params.path || '');
const candidates = getBeepAudioDirs().map(dir => safeRelativeToBase(dir, rel)).filter(Boolean);
sendBinaryFileFromCandidates(res, candidates);
});
app.get('/media/audio/beep/:path(*)', (req, res) => {
const rel = decodeURIComponent(req.params.path || '');
const candidates = getBeepAudioDirs().map(dir => safeRelativeToBase(dir, rel)).filter(Boolean);
sendBinaryFileFromCandidates(res, candidates);
});
app.get('/media/audio/music/:path(*)', (req, res) => {
const rel = decodeURIComponent(req.params.path || '');
const full = safeRelativeToBase(audioMusicDir, rel);
if (!full) return res.status(404).end();
sendBinaryFile(res, full);
});
app.get('/media/audio/voice/:path(*)', (req, res) => {
const rel = decodeURIComponent(req.params.path || '');
const full = safeRelativeToBase(audioVoiceDir, rel);
if (!full) return res.status(404).end();
sendBinaryFile(res, full);
});
app.get('/media/audio/line/:path(*)', (req, res) => {
const rel = decodeURIComponent(req.params.path || '');
const full = safeRelativeToBase(audioLineDir, rel);
if (!full) return res.status(404).end();
sendBinaryFile(res, full);
});
app.get('/media/audio/station/:path(*)', (req, res) => {
const rel = decodeURIComponent(req.params.path || '');
const full = safeRelativeToBase(audioStationDir, rel);
if (!full) return res.status(404).end();
sendBinaryFile(res, full);
});
app.get('/media/temp/:path(*)', (req, res) => {
const rel = decodeURIComponent(req.params.path || '');
const full = safeRelativeToBase(tempBroadcastDir, rel);
if (!full) return res.status(404).end();
sendBinaryFile(res, full);
});
app.get('/media', (req, res) => {
res.json({
success: true,
message: 'Use a concrete media path',
routes: [
'/media/audio/music/:file',
'/media/audio/voice/:file',
'/media/audio/line/:file',
'/media/audio/beep/:file',
'/media/audio/general/:file',
'/media/audio/station/:file',
'/media/temp/:file'
]
});
});
app.use(express.static(path.join(__dirname, 'public')));
app.set('view engine', 'ejs');
// Data Store (In-memory)
// stations[name] = { name, code, platform, audioCableGroup, version, lastSeen, upcomingTrains, upcomingCount, currentTrain, nodes: [] }
// nodes[]: { ws, hasStationPeripheral, stationPeripheralSide, hasBlockReader, hasCreateTarget, identityMode, nodeId, wsId, connectedAt }
let stations = {};
let audioCableGroups = {}; // audioCableGroups[id] = { id, name, color, description, stationNames: [] }
const audioCableTokens = new Map(); // cableGroupId -> { holderStationName, holderAudioId, holderPriority, acquiredAt, cancelToken }
let activeTrains = [];
let stationLogs = [];
let audioVersion = String(Date.now());
let versions = [];
let stationUpdate = {
version: '',
releaseId: '',
publishedAt: 0
};
const DATA_FILE = path.join(__dirname, 'server-data.json');
const stationAudioQueues = new Map();
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ===== A1 Core Helpers =====
function generateNodeId() {
return 'n_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
}
function generateWsId(ws) {
if (!ws) return '';
if (ws.__wsId) return ws.__wsId;
ws.__wsId = 'ws_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
return ws.__wsId;
}
function getGroupKeyForStation(stationOrName) {
if (!stationOrName) return null;
if (typeof stationOrName === 'string') {
const clean = String(stationOrName || '').trim();
return clean || null;
}
const name = String(stationOrName?.name || '').trim();
return name || null;
}
function getStationGroupMembers(stationName) {
const key = getGroupKeyForStation(stationName);
if (!key) return [];
const st = stations[key];
if (!st || !Array.isArray(st.nodes)) return [];
return st.nodes.slice();
}
function getStationMaster(stationName) {
const key = getGroupKeyForStation(stationName);
if (!key) return { master: null, ws: null };
const st = stations[key];
if (!st || !Array.isArray(st.nodes) || st.nodes.length === 0) return { master: null, ws: null };
// First pass: OPEN ws + hasStationPeripheral priority, weighted by lastSeen
let bestScore = -Infinity;
let bestNode = null;
const now = Date.now();
for (const node of st.nodes) {
if (!node.ws || node.ws.readyState !== WebSocket.OPEN) continue;
let score = (Number(node.lastSeen || st.lastSeen || now) % 1000000) / 1000000.0;
if (node.hasStationPeripheral) score += 1000;
if (score > bestScore) {
bestScore = score;
bestNode = node;
}
}
if (bestNode) return { master: bestNode, ws: bestNode.ws };
// Second pass: any OPEN ws (even without station peripheral)
for (const node of st.nodes) {
if (node.ws && node.ws.readyState === WebSocket.OPEN) {
return { master: node, ws: node.ws };
}
}
return { master: null, ws: null };
}
function getAllStationConnections(stationName) {
const out = [];
for (const node of getStationGroupMembers(stationName)) {
if (node.ws && node.ws.readyState === WebSocket.OPEN) out.push(node.ws);
}
return out;
}
function ensureStationRecord(name) {
const clean = String(name || '').trim();
if (!clean) return null;
if (!stations[clean]) {
stations[clean] = {
name: clean,
code: '',
platform: getPlatformNumberFromName(clean),
audioCableGroup: '',
version: '',
lastSeen: Date.now(),
nodes: []
};
normalizeStationRecord(stations[clean]);
}
if (!Array.isArray(stations[clean].nodes)) {
stations[clean].nodes = [];
}
return stations[clean];
}
function upsertStationNode(stationName, ws, data) {
const clean = String(stationName || '').trim();
if (!clean) return null;
const st = ensureStationRecord(clean);
if (!st) return null;
const wsId = generateWsId(ws);
const now = Date.now();
// Update station-level fields
st.lastSeen = now;
if (data?.stationCode) st.code = String(data.stationCode);
if (data?.stationPlatform) st.platform = String(data.stationPlatform);
if (data?.stationVersion) st.version = String(data.stationVersion);
// Sync cable group from station perspective (if not already set)
if (data?.audioCableGroup && !st.audioCableGroup) {
st.audioCableGroup = String(data.audioCableGroup);
const grp = audioCableGroups[st.audioCableGroup];
if (grp && Array.isArray(grp.stationNames) && !grp.stationNames.includes(clean)) {
grp.stationNames.push(clean);
}
}
// Create node-level fields
const nodeFields = {
hasStationPeripheral: false,
stationPeripheralSide: '',
hasBlockReader: false,
hasCreateTarget: false,
identityMode: '',
upcomingTrains: [],
upcomingCount: 0,
currentTrain: null,
tickRate: 20,
syncedAt: 0
};
if (data?.hasStationPeripheral !== undefined) nodeFields.hasStationPeripheral = Boolean(data.hasStationPeripheral);
if (data?.stationPeripheralSide !== undefined) nodeFields.stationPeripheralSide = String(data.stationPeripheralSide || '');
if (data?.hasBlockReader !== undefined) nodeFields.hasBlockReader = Boolean(data.hasBlockReader);
if (data?.hasCreateTarget !== undefined) nodeFields.hasCreateTarget = Boolean(data.hasCreateTarget);
if (data?.identityMode !== undefined) nodeFields.identityMode = String(data.identityMode || '');
if (data?.upcomingTrains !== undefined) nodeFields.upcomingTrains = Array.isArray(data.upcomingTrains) ? data.upcomingTrains : [];
if (data?.upcomingCount !== undefined) nodeFields.upcomingCount = Number(data.upcomingCount) || 0;
if (data?.currentTrain !== undefined) nodeFields.currentTrain = data.currentTrain || null;
if (data?.tickRate !== undefined) nodeFields.tickRate = Number(data.tickRate) || 20;
if (data?.syncedAt !== undefined) nodeFields.syncedAt = Number(data.syncedAt) || now;
nodeFields.lastSeen = now;
// Upsert by wsId
let node = st.nodes.find(n => n.wsId === wsId);
if (!node) {
// Also try to find by ws object (in case wsId was reset)
node = st.nodes.find(n => n.ws === ws);
}
if (node) {
Object.assign(node, nodeFields);
node.ws = ws;
node.connectedAt = node.connectedAt || now;
node.lastSeen = now;
} else {
node = {
...nodeFields,
ws,
wsId,
nodeId: generateNodeId(),
connectedAt: now,
lastSeen: now
};
st.nodes.push(node);
}
// Aggregate station-level schedule from the latest synced node
let bestSynced = 0;
let bestSyncedNode = null;
for (const n of st.nodes) {
if (n.syncedAt > bestSynced) {
bestSynced = n.syncedAt;
bestSyncedNode = n;
}
}
if (bestSyncedNode) {
st.upcomingTrains = bestSyncedNode.upcomingTrains || [];
st.upcomingCount = bestSyncedNode.upcomingCount || 0;
st.currentTrain = bestSyncedNode.currentTrain || null;
}
normalizeStationRecord(st);
return { st, node };
}
function removeStationWs(ws) {
const wsId = generateWsId(ws);
const affected = [];
for (const name of Object.keys(stations)) {
const st = stations[name];
if (!st || !Array.isArray(st.nodes)) continue;
const before = st.nodes.length;
st.nodes = st.nodes.filter(n => n.ws !== ws && n.wsId !== wsId);
if (before !== st.nodes.length) {
affected.push(name);
// Release any cable tokens held by this station
for (const [cgId, tok] of Array.from(audioCableTokens.entries())) {
if (tok && tok.holderStationName === name) {
if (tok.cancelToken) tok.cancelToken.cancelled = true;
audioCableTokens.delete(cgId);
}
}
}
}
// Notify affected groups (send station_group_state to remaining members)
for (const name of affected) {
sendStationGroupState(name);
}
return affected;
}
function getCableGroupByStationName(stationName) {
const clean = String(stationName || '').trim();
if (!clean) return null;
const st = stations[clean];
if (st && st.audioCableGroup) {
const grp = audioCableGroups[st.audioCableGroup];
if (grp) return grp;
}
for (const id of Object.keys(audioCableGroups)) {
const grp = audioCableGroups[id];
if (Array.isArray(grp?.stationNames) && grp.stationNames.includes(clean)) {
if (st) st.audioCableGroup = id;
return grp;
}
}
return null;
}
function sendWsToStationMaster(stationName, msgObj) {
const { ws } = getStationMaster(stationName);
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
try { ws.send(JSON.stringify(msgObj)); return true; } catch { return false; }
}
function sendStationGroupState(stationName) {
const clean = String(stationName || '').trim();
if (!clean) return;
const groupKey = getGroupKeyForStation(clean);
const members = getStationGroupMembers(clean);
const { master } = getStationMaster(clean);
const cableGrp = getCableGroupByStationName(clean);
const msg = {
type: 'station_group_state',
groupKey,
memberCount: members.length,
masterNodeId: master?.nodeId || '',
cableGroupId: cableGrp?.id || ''
};
for (const node of members) {
if (node.ws && node.ws.readyState === WebSocket.OPEN) {
try { node.ws.send(JSON.stringify(msg)); } catch {}
}
}
}
async function acquireCableToken(cableGroupId, stationName, audioId, priority, cancelToken) {
const cgId = String(cableGroupId || '').trim();
const stName = String(stationName || '').trim();
const audId = String(audioId || '');
const prio = Number(priority || 0);
if (!cgId) return { ok: true, reason: 'no_cable_group' };
if (!stName) return { ok: false, reason: 'no_station' };
const existing = audioCableTokens.get(cgId);
if (!existing) {
audioCableTokens.set(cgId, {
holderStationName: stName,
holderAudioId: audId,
holderPriority: prio,
acquiredAt: Date.now(),
cancelToken: cancelToken || null
});
sendWsToStationMaster(stName, {
type: 'cable_token_granted',
cableGroupId: cgId,
stationName: stName,
audioId: audId
});
return { ok: true };
}
// Same holder and audio - already granted
if (existing.holderStationName === stName && existing.holderAudioId === audId) {
return { ok: true, reason: 'already_holder' };
}
// Same holder but different audioId: release old (ignore if in-flight cancel already)
if (existing.holderStationName === stName) {
audioCableTokens.set(cgId, {
holderStationName: stName,
holderAudioId: audId,
holderPriority: prio,
acquiredAt: Date.now(),
cancelToken: cancelToken || null
});
return { ok: true, reason: 'same_station_updated' };
}
// Higher priority can preempt lower
if (prio > existing.holderPriority) {
sendWsToStationMaster(existing.holderStationName, {
type: 'audio_preempt',
reason: 'higher_priority',
byStationName: stName,
priority: prio,
audioId: audId
});
// Send denied to previous holder
sendWsToStationMaster(existing.holderStationName, {
type: 'cable_token_denied',
cableGroupId: cgId,
stationName: existing.holderStationName,
reason: 'preempted',
holderStationName: stName,
holderPriority: prio
});
if (existing.cancelToken) existing.cancelToken.cancelled = true;
await delay(200);
audioCableTokens.set(cgId, {
holderStationName: stName,
holderAudioId: audId,
holderPriority: prio,
acquiredAt: Date.now(),
cancelToken: cancelToken || null
});
sendWsToStationMaster(stName, {
type: 'cable_token_granted',
cableGroupId: cgId,
stationName: stName,
audioId: audId
});
return { ok: true, reason: 'preempted_lower' };
}
// Lower or equal priority: deny
sendWsToStationMaster(stName, {
type: 'cable_token_denied',
cableGroupId: cgId,
stationName: stName,
reason: 'lower_priority',
holderStationName: existing.holderStationName,
holderPriority: existing.holderPriority
});
return { ok: false, reason: 'lower_priority', holderStationName: existing.holderStationName, holderPriority: existing.holderPriority };
}
function releaseCableToken(cableGroupId, stationName, audioId) {
const cgId = String(cableGroupId || '').trim();
const stName = String(stationName || '').trim();
const audId = String(audioId || '');
if (!cgId) return false;
const existing = audioCableTokens.get(cgId);
if (!existing) return false;
if (existing.holderStationName !== stName) return false;
if (audId && existing.holderAudioId && existing.holderAudioId !== audId) return false;
if (existing.cancelToken) existing.cancelToken.cancelled = true;
audioCableTokens.delete(cgId);
return true;
}
function getLocalStationScriptVersion() {
try {
if (!fs.existsSync(stationScriptPath)) return '';
const content = fs.readFileSync(stationScriptPath, 'utf8');
const match = content.match(/local\s+APP_VERSION_TEXT\s*=\s*["']([^"']+)["']/);
return match ? String(match[1]).trim() : '';
} catch (e) {
log('ERROR', `Failed to read station script version: ${e.message}`);
return '';
}
}
function buildAbsoluteUrl(req, pathname) {
const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim();
const host = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim();
if (!host) return pathname;
return `${proto}://${host}${pathname}`;
}
function getStationUpdateSummary(req) {
return {
localVersion: getLocalStationScriptVersion(),
targetVersion: stationUpdate.version || '',
releaseId: stationUpdate.releaseId || '',
publishedAt: Number(stationUpdate.publishedAt || 0),
downloadPath: STATION_SCRIPT_DOWNLOAD_PATH,
downloadUrl: buildAbsoluteUrl(req, STATION_SCRIPT_DOWNLOAD_PATH)
};
}
function maybeSendStationUpdate(ws, stationName, stationVersion) {
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
if (!stationUpdate.releaseId || !stationUpdate.version) return false;
if (String(stationVersion || '').trim() === String(stationUpdate.version || '').trim()) return false;
try {
ws.send(JSON.stringify({
type: 'station_update',
version: stationUpdate.version,
releaseId: stationUpdate.releaseId,
publishedAt: Number(stationUpdate.publishedAt || 0),
downloadPath: STATION_SCRIPT_DOWNLOAD_PATH
}));
log('INFO', `Queued station update for "${stationName}" -> ${stationUpdate.version}`);
return true;
} catch (e) {
log('ERROR', `Failed to push station update to "${stationName}": ${e.message}`);
return false;
}
}
async function fetchGiteaScript() {
try {
const response = await fetch('https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/branch/main/station_broadcast.lua');
if (!response.ok) return null;
return await response.text();
} catch (e) {
return null;
}
}
async function fetchGiteaVersion() {
try {
const text = await fetchGiteaScript();
if (!text) return null;
const match = text.match(/local\s+APP_VERSION_TEXT\s*=\s*["']([^"']+)["']/);
return match ? String(match[1]).trim() : null;
} catch (e) {
return null;
}
}
async function publishStationUpdate(req) {
let version = null;
let giteaText = await fetchGiteaScript();
if (giteaText) {
const match = giteaText.match(/local\s+APP_VERSION_TEXT\s*=\s*["']([^"']+)["']/);
version = match ? String(match[1]).trim() : null;
}
if (!version) {
version = getLocalStationScriptVersion() || `build-${Date.now()}`;
log('WARN', `Failed to fetch Gitea version, falling back to local version: ${version}`);
} else if (giteaText) {
try {
fs.writeFileSync(stationScriptPath, giteaText, 'utf8');
log('INFO', `Synced station_broadcast.lua from Gitea to local (version ${version})`);
} catch (e) {
log('ERROR', `Failed to sync station_broadcast.lua to local: ${e.message}`);
}
}
const releaseId = String(Date.now());
stationUpdate = {
version,
releaseId,
publishedAt: Date.now()
};
saveData();
let onlineStations = 0;
let notifiedStations = 0;
for (const [name] of Object.entries(stations)) {
const { ws } = getStationMaster(name);
const st = stations[name];
if (!ws || ws.readyState !== WebSocket.OPEN) continue;
onlineStations++;
if (maybeSendStationUpdate(ws, name, st?.version)) {
notifiedStations++;
}
}
const summary = getStationUpdateSummary(req);
broadcastUpdate();
broadcastToConsole({
type: 'event',
title: '站点更新已发布',
message: `目标版本 ${summary.targetVersion || version},已通知 ${notifiedStations}/${onlineStations} 台在线主机。`
});
log('INFO', `Station update published: ${version} (${notifiedStations}/${onlineStations} online stations notified)`);
return {
...summary,
onlineStations,
notifiedStations
};
}
function getInterruptModeForPriority(priority) {
if (priority >= 3) 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 { ws } = getStationMaster(stationName);
if (ws && ws.readyState === WebSocket.OPEN) return ws;
if (fallbackWs && fallbackWs.readyState === WebSocket.OPEN) return fallbackWs;
return null;
}
function getStationGroupKey(station) {
if (!station) return null;
const name = String(station.name || '').trim();
return name || null;
}
function getStationGroupTarget(station) {
const groupKey = getGroupKeyForStation(station);
return groupKey ? `group:${groupKey}` : null;
}
function getStationGroups() {
const groups = new Map();
for (const k in stations) {
if (!Object.prototype.hasOwnProperty.call(stations, k)) continue;
const st = stations[k];
const key = getGroupKeyForStation(st);
if (!key) continue;
if (groups.has(key)) continue; // already processed by exact name
const code = normalizeStationCode(st.code || getStationCodeFromName(st.name)) || '';
const platform = normalizeStationPlatform(st.platform || getPlatformNumberFromName(st.name));
const cableGrp = getCableGroupByStationName(st.name);
const { master } = getStationMaster(st.name);
const nodes = Array.isArray(st.nodes) ? st.nodes : [];
let hostCount = 0;
let slaveCount = 0;
let hasAnyStationPeripheral = false;
let hasBlockReader = false;
let hasCreateTarget = false;
let lastSeen = st.lastSeen || 0;
const membersArr = nodes.map((node) => {
const isOnline = !!(node.ws && node.ws.readyState === WebSocket.OPEN);
const isHost = Boolean(node.hasStationPeripheral);
if (isHost) {
hostCount++;
hasAnyStationPeripheral = true;
} else {
slaveCount++;
}
if (node.hasBlockReader) hasBlockReader = true;
if (node.hasCreateTarget) hasCreateTarget = true;
const nodeLastSeen = Number(node.lastSeen || 0);
if (nodeLastSeen > lastSeen) lastSeen = nodeLastSeen;
const tags = [];
if (node.hasStationPeripheral) tags.push('STATION');
if (node.hasBlockReader) tags.push('BLOCKREADER');
if (node.hasCreateTarget) tags.push('TARGET');
return {
nodeId: node.nodeId || '',
wsId: node.wsId || '',
online: isOnline,
isMaster: master && master.nodeId === node.nodeId,
connectedAt: Number(node.connectedAt) || 0,
lastSeen: nodeLastSeen,
hasStationPeripheral: isHost,
stationPeripheralSide: node.stationPeripheralSide || '',
hasBlockReader: Boolean(node.hasBlockReader),
hasCreateTarget: Boolean(node.hasCreateTarget),
identityMode: node.identityMode || '',
tickRate: Number(node.tickRate) || 20,
memberTags: tags
};
});
const memberNames = nodes.map(n => n.nodeId || '');
const mergedTags = ['CC'];
if (hostCount > 0) mergedTags.push(`HOST:${hostCount}`);
if (slaveCount > 0) mergedTags.push(`SLAVE:${slaveCount}`);
if (nodes.length > 1) mergedTags.push(`GROUP:${nodes.length}`);
if (hasAnyStationPeripheral) mergedTags.push('HAS_STATION');
if (hasBlockReader) mergedTags.push('HAS_BLOCKREADER');
if (hasCreateTarget) mergedTags.push('HAS_TARGET');
groups.set(key, {
key,
target: `group:${key}`,
name: st.name,
code,
platform,
lastSeen,
version: st.version || '',
source: 'cc',
tags: mergedTags,
memberNames,
memberCount: nodes.length,
primaryName: st.name,
hasStationPeripheral: Boolean(master?.hasStationPeripheral),
hasBlockReader,
hasCreateTarget,
hasAnyStationPeripheral,
hostCount,
slaveCount,
upcomingCount: Number(st.upcomingCount) || 0,
upcomingTrains: Array.isArray(st.upcomingTrains) ? st.upcomingTrains.slice() : [],
currentTrain: st.currentTrain || null,
members: membersArr,
nodes: membersArr,
masterNodeId: master?.nodeId || '',
audioCableGroup: cableGrp ? {
id: cableGrp.id,
name: cableGrp.name,
color: cableGrp.color || ''
} : null,
cableGroupId: cableGrp?.id || ''
});
}
return Array.from(groups.values());
}
function resolveStationGroup(selector) {
const clean = String(selector || '').trim();
if (!clean) return null;
if (Object.prototype.hasOwnProperty.call(stations, clean)) {
const st = stations[clean];
const nodes = Array.isArray(st.nodes) ? st.nodes : [];
const code = normalizeStationCode(st.code || getStationCodeFromName(st.name)) || '';
const platform = normalizeStationPlatform(st.platform || getPlatformNumberFromName(st.name));
const { master } = getStationMaster(clean);
return {
target: `group:${clean}`,
name: st.name,
code,
platform,
memberNames: nodes.map(n => n.nodeId || ''),
memberCount: nodes.length,
primaryName: st.name,
masterNodeId: master?.nodeId || ''
};
}
for (const group of getStationGroups()) {
if (group.target === clean || group.name === clean || group.key === clean) {
return group;
}
}
return null;
}
function expandStationTargets(selectors) {
const groups = [];
const seenTargets = new Set();
for (const raw of Array.isArray(selectors) ? selectors : []) {
const group = resolveStationGroup(raw);
if (!group) continue;
const target = String(group.target || group.name || '').trim();
if (!target || seenTargets.has(target)) continue;
seenTargets.add(target);
groups.push(group);
}
return groups;
}
function getMergedStationList() {
return getStationGroups();
}
function sendAudioControl(stationName, msg, fallbackWs) {
const allWs = getAllStationConnections(stationName);
if (allWs.length === 0) {
if (fallbackWs && fallbackWs.readyState === WebSocket.OPEN) {
try { fallbackWs.send(JSON.stringify(msg)); return true; } catch { return false; }
}
return false;
}
let ok = false;
const payload = JSON.stringify(msg);
for (const ws of allWs) {
try { ws.send(payload); ok = true; } catch {}
}
return ok;
}
async function sendAudioBufferToStation(stationName, fallbackWs, task) {
const { ws: targetWs } = getStationMaster(stationName);
const masterWs = targetWs || (fallbackWs && fallbackWs.readyState === WebSocket.OPEN ? fallbackWs : null);
if (!masterWs) throw new Error(`Station "${stationName}" not connected`);
const cableGrp = getCableGroupByStationName(stationName);
const audioId = String(task.audioId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
const prio = Number(task.priority || 0);
if (cableGrp) {
const res = await acquireCableToken(cableGrp.id, stationName, audioId, prio, task.cancelToken || null);
if (!res.ok) {
throw new Error(`Cable token denied: ${res.reason || 'unknown'} (holder=${res.holderStationName || 'none'} p=${res.holderPriority ?? 0})`);
}
}
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: prio,
interruptMode: task.interruptMode || getInterruptModeForPriority(prio),
source: task.source || 'unknown',
cableGroupId: cableGrp?.id || ''
};
masterWs.send(JSON.stringify(startMsg));
if (streamUrl) {
if (cableGrp && durationSec > 0) {
setTimeout(() => {
try { releaseCableToken(cableGrp.id, stationName, audioId); } catch {}
}, Math.max(100, Math.floor(durationSec * 1000) + 500));
}
return { audioId, cancelled: false, durationSec, cableGroupId: cableGrp?.id || '' };
}
const token = task.cancelToken;
const SINGLE_SEND_LIMIT = 32 * 1024;
const CHUNK_SIZE = 16 * 1024;
try {
if (buf.length <= SINGLE_SEND_LIMIT) {
if (token && token.cancelled) {
if (cableGrp) releaseCableToken(cableGrp.id, stationName, audioId);
return { audioId, cancelled: true, durationSec, cableGroupId: cableGrp?.id || '' };
}
masterWs.send(buf);
} else {
for (let offset = 0; offset < buf.length; offset += CHUNK_SIZE) {
if (token && token.cancelled) {
if (cableGrp) releaseCableToken(cableGrp.id, stationName, audioId);
return { audioId, cancelled: true, durationSec, cableGroupId: cableGrp?.id || '' };
}
const end = Math.min(offset + CHUNK_SIZE, buf.length);
masterWs.send(buf.slice(offset, end));
const isInitial = offset < CHUNK_SIZE * 4;
await delay(isInitial ? 20 : 60);
}
}
if (token && token.cancelled) {
if (cableGrp) releaseCableToken(cableGrp.id, stationName, audioId);
return { audioId, cancelled: true, durationSec, cableGroupId: cableGrp?.id || '' };
}
masterWs.send(JSON.stringify({
type: 'audio_end',
stationName,
audioId,
durationSec
}));
if (cableGrp && durationSec > 0) {
setTimeout(() => {
try { releaseCableToken(cableGrp.id, stationName, audioId); } catch {}
}, Math.max(100, Math.floor(durationSec * 1000) + 500));
} else if (cableGrp) {
releaseCableToken(cableGrp.id, stationName, audioId);
}
return { audioId, cancelled: false, durationSec, cableGroupId: cableGrp?.id || '' };
} catch (e) {
if (cableGrp) {
try { releaseCableToken(cableGrp.id, stationName, audioId); } catch {}
}
throw e;
}
}
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 { ws: targetWs } = getStationMaster(stationName);
const masterWs = targetWs || (fallbackWs && fallbackWs.readyState === WebSocket.OPEN ? fallbackWs : null);
if (!masterWs) throw new Error(`Station "${stationName}" not connected`);
const cableGrp = getCableGroupByStationName(stationName);
const audioId = String(task.audioId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
const prio = Number(task.priority || 0);
const durationSec = Number(task.durationSec || 0);
if (cableGrp) {
const res = await acquireCableToken(cableGrp.id, stationName, audioId, prio, task.cancelToken || null);
if (!res.ok) {
throw new Error(`Cable token denied: ${res.reason || 'unknown'} (holder=${res.holderStationName || 'none'} p=${res.holderPriority ?? 0})`);
}
}
const sourcePath = String(task.sourcePath || '').trim();
if (!sourcePath) {
if (cableGrp) releaseCableToken(cableGrp.id, stationName, audioId);
throw new Error('Live WAV sourcePath required');
}
if (!fs.existsSync(sourcePath)) {
if (cableGrp) releaseCableToken(cableGrp.id, stationName, audioId);
throw new Error(`Live WAV source missing: ${sourcePath}`);
}
const loop = !!task.loop;
const token = task.cancelToken;
const sampleRate = 48000;
const channels = 1;
const bitsPerSample = 16;
const chunkDurationSec = 0.25;
const chunkBytes = Math.max(4096, Math.floor(sampleRate * channels * (bitsPerSample / 8) * chunkDurationSec));
masterWs.send(JSON.stringify({
type: 'audio_start',
stationName,
audioId,
byteLength: 0,
sampleRate,
channels,
audioFormat: 'wav-stream',
loop,
priority: prio,
interruptMode: task.interruptMode || getInterruptModeForPriority(prio),
source: task.source || 'unknown',
cableGroupId: cableGrp?.id || ''
}));
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 releaseToken = () => {
if (cableGrp) {
try { releaseCableToken(cableGrp.id, stationName, audioId); } catch {}
}
};
const finish = (result, error) => {
if (settled) return;
settled = true;
if (task.onCancel === cancelStream) task.onCancel = null;
if (error) {
releaseToken();
reject(error);
return;
}
try {
const { ws: endWs } = getStationMaster(stationName);
if (endWs && endWs.readyState === WebSocket.OPEN) {
endWs.send(JSON.stringify({
type: 'audio_end',
stationName,
audioId,
durationSec: result.durationSec
}));
}
} catch {}
if (cableGrp && !loop && durationSec > 0) {
setTimeout(() => releaseToken(), Math.max(100, Math.floor(durationSec * 1000) + 500));
} else {
releaseToken();
}
resolve({ ...result, cableGroupId: cableGrp?.id || '' });
};
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: fws } = getStationMaster(stationName);
const sendWs = fws || masterWs;
if (!sendWs || sendWs.readyState !== WebSocket.OPEN) {
cancelStream();
return;
}
try {
sendWs.send(buildWavChunkFromPcm(pcmChunk, sampleRate, channels, bitsPerSample));
} catch (err) {
finish(null, err);
}
};
proc.stdout.on('data', (chunk) => {
if (!chunk || !chunk.length || settled) return;
if (token && token.cancelled) {
cancelStream();
return;
}
pcmPending = pcmPending.length > 0 ? Buffer.concat([pcmPending, chunk]) : Buffer.from(chunk);
while (pcmPending.length >= chunkBytes && !settled) {
const part = pcmPending.subarray(0, chunkBytes);
pcmPending = pcmPending.subarray(chunkBytes);
flushChunk(part);
}
});
proc.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
proc.on('error', (err) => {
finish(null, err);
});
proc.on('close', (code, signal) => {
const cancelled = token && token.cancelled;
if (!cancelled && pcmPending.length > 0) {
flushChunk(pcmPending);
pcmPending = Buffer.alloc(0);
}
log(cancelled ? 'INFO' : 'SUCCESS', `Live WAV chunk stream end for ${stationName}: ${path.basename(sourcePath)}${cancelled ? ' (cancelled)' : ''}`);
if (!cancelled && code !== 0) {
finish(null, new Error((stderr || `ffmpeg exited with code ${code ?? 'unknown'} signal ${signal ?? 'none'}`).trim()));
return;
}
finish({ audioId, cancelled: !!cancelled, durationSec: loop ? 0 : durationSec });
});
});
}
const stationMusicStates = new Map();
function hasSupportedAudioExtension(name, exts = MUSIC_AUDIO_EXTENSIONS) {
const lower = String(name || '').trim().toLowerCase();
return exts.some(ext => lower.endsWith(ext));
}
function stripSupportedAudioExtension(name, exts = MUSIC_AUDIO_EXTENSIONS) {
const value = String(name || '').trim();
for (const ext of exts) {
if (value.toLowerCase().endsWith(ext)) {
return value.slice(0, -ext.length).trim();
}
}
return value;
}
function normalizeBaseName(name) {
const s = stripSupportedAudioExtension(name);
if (!s) return null;
const base = s.trim();
return base || null;
}
function getApiBaseUrl() {
return String(STABROADCAST_API_BASE || '').replace(/\/+$/g, '');
}
function getManagedMediaUrl(kind, fileName) {
const base = getApiBaseUrl();
return `${base}/media/${kind}/${encodeURIComponent(fileName)}`;
}
function listMusicTracks() {
const out = new Set();
const scanDirs = [audioMusicDir];
if (fs.existsSync(audioMusicDir) && fs.readdirSync(audioMusicDir).length === 0) {
scanDirs.push(...getBeepAudioDirs());
}
for (const dir of scanDirs) {
if (!fs.existsSync(dir)) continue;
for (const file of fs.readdirSync(dir)) {
if (!file || file.startsWith('.')) continue;
const full = path.join(dir, file);
if (!fs.existsSync(full) || !fs.statSync(full).isFile()) continue;
if (!hasSupportedAudioExtension(file)) continue;
const base = normalizeBaseName(file);
if (base) out.add(base);
}
}
return Array.from(out).sort((a, b) => a.localeCompare(b));
}
function getWavDurationSec(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length < 44) return 0;
if (buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') return 0;
let offset = 12;
let sampleRate = 0;
let channels = 0;
let bitsPerSample = 0;
let dataSize = 0;
while (offset + 8 <= buffer.length) {
const chunkId = buffer.toString('ascii', offset, offset + 4);
const chunkSize = buffer.readUInt32LE(offset + 4);
const dataOffset = offset + 8;
if (dataOffset + chunkSize > buffer.length) break;
if (chunkId === 'fmt ' && chunkSize >= 16) {
channels = buffer.readUInt16LE(dataOffset + 2);
sampleRate = buffer.readUInt32LE(dataOffset + 4);
bitsPerSample = buffer.readUInt16LE(dataOffset + 14);
} else if (chunkId === 'data') {
dataSize = chunkSize;
break;
}
offset = dataOffset + chunkSize + (chunkSize % 2);
}
if (!sampleRate || !channels || !bitsPerSample || !dataSize) return 0;
return dataSize / (sampleRate * channels * (bitsPerSample / 8));
}
function resolveAudioPathWithFuzzy(dir, base, ext) {
const filename = `${base}${ext}`;
const direct = path.join(dir, filename);
if (fs.existsSync(direct)) return direct;
const result = findFileCaseInsensitive(dir, filename);
return result || null;
}
function getPlaybackAudioByBaseName(baseName) {
const base = normalizeBaseName(baseName);
if (!base) throw new Error('Invalid track name');
for (const ext of STREAMABLE_AUDIO_EXTENSIONS) {
const searchDirs = [
{ dir: audioMusicDir, kind: 'audio/music' },
...getBeepAudioDirs().map(d => ({ dir: d, kind: 'audio/beep' })),
{ dir: audioStationDir, kind: 'audio/station' },
{ dir: audioLineDir, kind: 'audio/station' }
];
for (const { dir, kind } of searchDirs) {
const p = resolveAudioPathWithFuzzy(dir, base, ext);
if (!p) continue;
let durationSec = 0;
if (ext === '.wav') {
const buffer = fs.readFileSync(p);
durationSec = getWavDurationSec(buffer) || 0;
}
return {
sourcePath: p,
audioFormat: 'stream',
streamUrl: getManagedMediaUrl(kind, path.basename(p)),
durationSec
};
}
}
for (const { dir } of [
{ dir: audioMusicDir },
...getBeepAudioDirs().map(d => ({ dir: d })),
{ dir: audioStationDir },
{ dir: audioLineDir }
]) {
const p = resolveAudioPathWithFuzzy(dir, base, '.dfpwm');
if (!p) continue;
const buffer = fs.readFileSync(p);
return {
buffer,
audioFormat: 'dfpwm',
durationSec: buffer.length / 6000
};
}
throw new Error(`Audio not found: ${base}`);
}
function cancelStationAudioTask(task) {
if (!task) return;
if (task.cancelToken) task.cancelToken.cancelled = true;
if (typeof task.onCancel === 'function') {
try { task.onCancel(); } catch {}
}
if (task.endTimer) {
try { clearTimeout(task.endTimer); } catch {}
task.endTimer = null;
}
}
function stopStationMusic(stationName, fallbackWs) {
const cleanStation = String(stationName || '').trim();
const q = getStationAudioQueue(cleanStation);
q.queue = q.queue.filter(t => t?.source !== 'music');
if (q.current && q.current.source === 'music') {
cancelStationAudioTask(q.current);
}
sendAudioControl(cleanStation, { type: 'audio_stop', scope: 'music', mode: 'current', reason: 'music_stop' }, fallbackWs);
}
function getStationAudioQueue(stationName) {
const st = getStationData(stationName);
const key = st ? st.name : String(stationName || '').trim();
if (!stationAudioQueues.has(key)) {
stationAudioQueues.set(key, {
processing: false,
current: null,
queue: []
});
}
return stationAudioQueues.get(key);
}
function enqueueStationAudioTask(stationName, fallbackWs, task) {
const cleanStation = String(stationName || '').trim();
const q = getStationAudioQueue(cleanStation);
const newP = Number(task.priority || 0);
const cur = q.current;
if (task.interruptMode === 'all') {
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;
const cableGrp = getCableGroupByStationName(cleanStation);
let tokenAcquiredForThisTask = false;
try {
if (task.consoleStreamStart) broadcastToConsole(task.consoleStreamStart);
const result = task.audioFormat === 'wav-stream-live'
? await sendLiveWavChunksToStation(cleanStation, fallbackWs, task)
: await sendAudioBufferToStation(cleanStation, fallbackWs, task);
tokenAcquiredForThisTask = true;
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 (cableGrp && !tokenAcquiredForThisTask && task.audioId) {
try { releaseCableToken(cableGrp.id, cleanStation, task.audioId); } catch {}
}
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 && typeof data.stations === 'object') {
const raw = data.stations;
const newStations = {};
for (const key of Object.keys(raw)) {
const old = raw[key];
if (!old) continue;
const name = String(old.name || key).trim();
newStations[name] = {
name,
code: String(old.code || ''),
platform: String(old.platform || getPlatformNumberFromName(name)),
audioCableGroup: String(old.audioCableGroup || ''),
version: String(old.version || ''),
lastSeen: Number(old.lastSeen || Date.now()),
upcomingCount: Number(old.upcomingCount || 0),
upcomingTrains: Array.isArray(old.upcomingTrains) ? old.upcomingTrains : [],
currentTrain: old.currentTrain || null,
nodes: Array.isArray(old.nodes) ? old.nodes.map(n => ({
...n,
ws: null,
nodeId: n.nodeId || generateNodeId(),
wsId: n.wsId || '',
connectedAt: Number(n.connectedAt || 0),
lastSeen: Number(n.lastSeen || 0)
})) : []
};
normalizeStationRecord(newStations[name]);
}
stations = newStations;
}
if (data.audioCableGroups && typeof data.audioCableGroups === 'object') {
audioCableGroups = data.audioCableGroups;
for (const id of Object.keys(audioCableGroups)) {
const g = audioCableGroups[id];
if (!Array.isArray(g?.stationNames)) g.stationNames = [];
for (const stName of g.stationNames) {
if (stations[stName] && !stations[stName].audioCableGroup) {
stations[stName].audioCableGroup = id;
}
}
}
}
if (data.activeTrains) activeTrains = data.activeTrains;
if (data.audioVersion) audioVersion = String(data.audioVersion);
if (Array.isArray(data.versions)) versions = data.versions;
if (data.stationUpdate && typeof data.stationUpdate === 'object') {
stationUpdate = {
version: String(data.stationUpdate.version || ''),
releaseId: String(data.stationUpdate.releaseId || ''),
publishedAt: Number(data.stationUpdate.publishedAt || 0)
};
}
refreshStationIndex();
log('INFO', 'Data loaded from disk');
} catch (e) {
log('ERROR', `Failed to load data: ${e.message}`);
}
}
}
function saveData() {
try {
const stationsToSave = {};
for (const key in stations) {
const st = stations[key];
if (!st) continue;
const strippedNodes = Array.isArray(st.nodes)
? st.nodes.map(n => {
const { ws: _w, ...rest } = n;
return rest;
})
: [];
stationsToSave[key] = {
name: st.name,
code: st.code || '',
platform: st.platform || '',
audioCableGroup: st.audioCableGroup || '',
version: st.version || '',
lastSeen: st.lastSeen || 0,
upcomingCount: Number(st.upcomingCount) || 0,
upcomingTrains: Array.isArray(st.upcomingTrains) ? st.upcomingTrains : [],
currentTrain: st.currentTrain || null,
nodes: strippedNodes
};
}
const data = {
stations: stationsToSave,
audioCableGroups,
activeTrains,
audioVersion,
versions,
stationUpdate
};
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
} catch (e) {
log('ERROR', `Failed to save data: ${e.message}`);
}
}
function normalizeVersionString(input) {
if (input === undefined || input === null) return '';
if (typeof input !== 'string') input = String(input);
input = input.trim();
if (input.length > 64) input = input.slice(0, 64);
return input;
}
function resolveManagedPath(inputPath) {
const normalized = String(inputPath || '').replace(/\\/g, '/').replace(/^\/+/, '').trim();
if (!normalized) return null;
if (normalized === 'list.txt' || normalized === 'uploads/list.txt') {
return { logicalPath: 'list.txt', fullPath: listFilePath };
}
if (
normalized === 'Audio/beep/line.txt' ||
normalized === 'uploads/audio/beep/line.txt' ||
normalized === 'Audio/general/line.txt' ||
normalized === 'uploads/audio/general/line.txt'
) {
const fullPath = fs.existsSync(path.join(audioBeepDir, 'line.txt'))
? path.join(audioBeepDir, 'line.txt')
: path.join(audioGeneralLegacyDir, 'line.txt');
return { logicalPath: 'Audio/beep/line.txt', fullPath };
}
const prefixes = [
{ prefix: 'Audio/music/', baseDir: audioMusicDir, logicalPrefix: 'Audio/music/' },
{ prefix: 'Audio/voice/', baseDir: audioVoiceDir, logicalPrefix: 'Audio/voice/' },
{ prefix: 'Audio/beep/', baseDir: audioBeepDir, logicalPrefix: 'Audio/beep/' },
{ prefix: 'Audio/general/', baseDir: audioGeneralLegacyDir, logicalPrefix: 'Audio/beep/' },
{ prefix: 'uploads/audio/music/', baseDir: audioMusicDir, logicalPrefix: 'Audio/music/' },
{ prefix: 'uploads/audio/voice/', baseDir: audioVoiceDir, logicalPrefix: 'Audio/voice/' },
{ prefix: 'uploads/audio/beep/', baseDir: audioBeepDir, logicalPrefix: 'Audio/beep/' },
{ prefix: 'uploads/audio/general/', baseDir: audioGeneralLegacyDir, logicalPrefix: 'Audio/beep/' },
{ prefix: 'Audio/line/', baseDir: audioLineDir, logicalPrefix: 'Audio/line/' },
{ prefix: 'uploads/audio/line/', baseDir: audioLineDir, logicalPrefix: 'Audio/line/' },
{ 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;
let relativeName = normalized.slice(entry.prefix.length).trim();
if (!relativeName) return null;
relativeName = relativeName.replace(/\.\./g, '');
if (!relativeName) return null;
return {
logicalPath: `${entry.logicalPrefix}${relativeName}`,
fullPath: path.join(entry.baseDir, ...relativeName.split('/'))
};
}
return null;
}
function scanDirFiles(baseDir, logicalPrefix, fileType, files, seenNames) {
if (!fs.existsSync(baseDir)) return;
const all = listAllFilesRecursive(baseDir, []);
for (const fullPath of all) {
let relative = path.relative(baseDir, fullPath).replace(/\\/g, '/');
const name = path.basename(relative);
if (name.endsWith('.dfpwm')) {
const wavName = name.replace(/\.dfpwm$/, '.wav');
const wavRelative = relative.replace(/\.dfpwm$/, '.wav');
if (fs.existsSync(path.join(baseDir, ...wavRelative.split('/')))) continue;
}
if (seenNames) {
if (seenNames.has(relative)) continue;
seenNames.add(relative);
}
const stats = fs.statSync(fullPath);
files.push({
name: relative,
path: `${logicalPrefix}${relative}`,
type: fileType,
size: stats.size,
mtime: stats.mtimeMs
});
}
}
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
});
}
scanDirFiles(audioMusicDir, 'Audio/music/', 'music', files);
scanDirFiles(audioVoiceDir, 'Audio/voice/', 'voice', files);
scanDirFiles(audioLineDir, 'Audio/line/', 'line', files);
scanDirFiles(audioStationDir, 'Audio/station/', 'station', files);
const beepSeen = new Set();
for (const dir of getBeepAudioDirs()) {
if (!fs.existsSync(dir)) continue;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
const name = entry.name;
if (beepSeen.has(name)) continue;
beepSeen.add(name);
if (name.endsWith('.dfpwm')) {
const wavName = name.replace(/\.dfpwm$/, '.wav');
if (beepSeen.has(wavName) || fs.existsSync(path.join(dir, wavName))) continue;
}
const filePath = path.join(dir, name);
const stats = fs.statSync(filePath);
files.push({
name: name,
path: `Audio/beep/${name}`,
type: name === 'line.txt' ? 'config' : 'beep',
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 listAllFilesRecursive(dir, extensions) {
if (!fs.existsSync(dir)) return [];
const results = [];
function walk(currentDir) {
let entries;
try {
entries = fs.readdirSync(currentDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.isFile()) {
if (!extensions || extensions.length === 0) {
results.push(fullPath);
} else {
const ext = path.extname(entry.name).toLowerCase();
if (extensions.includes(ext)) {
results.push(fullPath);
}
}
}
}
}
walk(dir);
return results;
}
function findFileCaseInsensitive(dir, filename) {
if (!fs.existsSync(dir)) return null;
const lowerFilename = filename.toLowerCase();
const ext = path.extname(lowerFilename);
const baseWithoutExt = lowerFilename.slice(0, lowerFilename.length - ext.length);
const suffixMatchPattern = '/' + baseWithoutExt + ext;
const suffixMatchPattern2 = '\\' + baseWithoutExt + ext;
try {
const files = fs.readdirSync(dir);
const directMatch = files.find(f => f.toLowerCase() === lowerFilename);
if (directMatch) {
return path.join(dir, directMatch);
}
} catch {}
const allFiles = listAllFilesRecursive(dir, [ext]);
for (const fullPath of allFiles) {
const normalized = fullPath.toLowerCase();
if (normalized.endsWith(suffixMatchPattern) || normalized.endsWith(suffixMatchPattern2)) {
return fullPath;
}
}
return null;
}
function resolveSequenceFiles(seq) {
ensureSilenceWav();
const files = [];
for (const item of seq) {
if (item === '__DELAY_1__') {
files.push(silence1sWavPath);
continue;
}
const dirsToSearch = [
audioStationDir,
audioLineDir,
audioVoiceDir,
...getBeepAudioDirs()
];
let found = null;
for (const dir of dirsToSearch) {
found = findFileCaseInsensitive(dir, `${item}.wav`);
if (found) break;
}
if (found) {
files.push(found);
} else {
log('WARN', `HLS audio missing on server (wav required): ${item}`);
}
}
if (HLS_TAIL_SILENCE_SEC > 0) {
for (let i = 0; i < HLS_TAIL_SILENCE_SEC; i++) files.push(silence1sWavPath);
}
return files;
}
function buildSequenceWavFile(seq) {
const files = resolveSequenceFiles(seq);
if (files.length === 0) return null;
const fileName = `sequence-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`;
const outWavPath = path.join(tempBroadcastDir, fileName);
const inputs = files.map(f => `-i "${f}"`).join(' ');
const filterResamples = files.map((_, i) => `[${i}:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a${i}]`).join(';');
const filterConcat = files.map((_, i) => `[a${i}]`).join('') + `concat=n=${files.length}:v=0:a=1[a]`;
const filterComplex = `${filterResamples};${filterConcat}`;
try {
execSync(`ffmpeg -hide_banner -loglevel error -y ${inputs} -filter_complex "${filterComplex}" -map "[a]" -ac 1 -ar 48000 -c:a pcm_s16le "${outWavPath}"`);
const buffer = fs.readFileSync(outWavPath);
const durationSec = getWavDurationSec(buffer) || 0;
setTimeout(() => {
try { fs.unlinkSync(outWavPath); } catch {}
}, Math.max(10 * 60 * 1000, Math.ceil(durationSec * 2000)));
return {
audioFormat: 'wav-stream-live',
sourcePath: outWavPath,
streamUrl: getManagedMediaUrl('temp', fileName),
durationSec
};
} catch (e) {
log('ERROR', `buildSequenceWavFile failed: ${e.message}`);
return null;
}
}
function runFfmpegRtmpPush(streamName, seq) {
const state = getOrCreateHlsStreamState(streamName);
const files = resolveSequenceFiles(seq);
if (files.length === 0) {
state.status = 'idle';
state.lastError = 'No playable wav files in sequence';
return Promise.resolve();
}
const rtmpUrl = `${SRS_RTMP_BASE.replace(/\/+$/g, '')}/${encodeURIComponent(streamName)}`;
state.lastSeq = seq;
state.lastError = null;
state.lastExitCode = null;
if (HLS_PREBUILD) {
const tmpAacPath = path.join(tmpDir, `prebuild-${Date.now()}-${Math.random().toString(16).slice(2)}.aac`);
const inputs = files.map(f => `-i "${f}"`).join(' ');
const filterResamples = files.map((_, i) => `[${i}:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a${i}]`).join(';');
const filterConcat = files.map((_, i) => `[a${i}]`).join('') + `concat=n=${files.length}:v=0:a=1[a]`;
const filterComplex = `${filterResamples};${filterConcat}`;
const buildArgs = [
'-hide_banner',
'-loglevel',
'error',
'-y'
];
files.forEach(f => { buildArgs.push('-i', f); });
buildArgs.push(
'-filter_complex',
filterComplex,
'-map',
'[a]',
'-ac',
'1',
'-ar',
'48000',
'-c:a',
'aac',
'-b:a',
'128k',
'-f',
'adts',
tmpAacPath
);
state.status = 'building';
state.lastStartAt = Date.now();
const buildProc = spawn('ffmpeg', buildArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
let buildStderr = '';
buildProc.stderr.on('data', (buf) => {
buildStderr += buf.toString('utf8');
if (buildStderr.length > 8000) buildStderr = buildStderr.slice(-8000);
});
return new Promise((resolve) => {
buildProc.on('close', (buildCode) => {
if (buildCode !== 0) {
state.lastExitCode = buildCode;
state.status = 'idle';
state.lastEndAt = Date.now();
state.lastError = (buildStderr || `ffmpeg exited with code ${buildCode}`).trim();
log('ERROR', `HLS build failed for ${streamName}: ${state.lastError}`);
try { fs.unlinkSync(listPath); } catch {}
try { fs.unlinkSync(tmpAacPath); } catch {}
resolve();
return;
}
const pushArgs = [
'-hide_banner',
'-loglevel',
'error',
'-re',
'-i',
tmpAacPath,
'-c:a',
'copy',
'-f',
'flv',
rtmpUrl
];
state.status = 'pushing';
const proc = spawn('ffmpeg', pushArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
state.currentProcess = proc;
let stderr = '';
proc.stderr.on('data', (buf) => {
stderr += buf.toString('utf8');
if (stderr.length > 8000) stderr = stderr.slice(-8000);
});
proc.on('close', (code) => {
state.lastExitCode = code;
state.status = 'idle';
state.lastEndAt = Date.now();
state.currentProcess = null;
if (code !== 0) {
state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim();
// Suppress HLS push failed logs
// log('ERROR', `HLS push failed for ${streamName}: ${state.lastError}`);
}
try { fs.unlinkSync(tmpAacPath); } catch {}
resolve();
});
});
});
}
const filterResamples = files.map((_, i) => `[${i}:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a${i}]`).join(';');
const filterConcat = files.map((_, i) => `[a${i}]`).join('') + `concat=n=${files.length}:v=0:a=1[a]`;
const filterComplex = `${filterResamples};${filterConcat}`;
const args = [
'-hide_banner',
'-loglevel',
'error',
'-re'
];
files.forEach(f => { args.push('-i', f); });
args.push(
'-filter_complex',
filterComplex,
'-map',
'[a]',
'-ac',
'1',
'-ar',
'48000',
'-c:a',
'aac',
'-b:a',
'128k',
'-f',
'flv',
rtmpUrl
);
state.status = 'pushing';
state.lastStartAt = Date.now();
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
state.currentProcess = proc;
let stderr = '';
proc.stderr.on('data', (buf) => {
stderr += buf.toString('utf8');
if (stderr.length > 8000) stderr = stderr.slice(-8000);
});
return new Promise((resolve) => {
proc.on('close', (code) => {
state.lastExitCode = code;
state.status = 'idle';
state.lastEndAt = Date.now();
state.currentProcess = null;
if (code !== 0) {
state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim();
// Suppress HLS push failed logs
// 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', () => {
removeStationWs(ws);
});
});
function handleMessage(ws, data) {
if (data.type === 'console_connect') {
ws.isConsole = true;
ws.send(JSON.stringify({
type: 'init_state',
stations: getMergedStationList(),
activeTrains,
logs: stationLogs,
stationUpdate: getStationUpdateSummary(),
audioCableGroups: Object.values(audioCableGroups)
}));
return;
}
switch (data.type) {
case 'station_register':
handleStationRegister(ws, data);
break;
case 'train_arrival':
handleTrainArrival(ws, data);
break;
case 'train_approaching':
handleTrainApproaching(ws, data);
break;
case 'train_alert':
handleTrainAlert(ws, data);
break;
case 'train_departure':
handleTrainDeparture(ws, data);
break;
case 'play_sequence':
break;
case 'request_station_time':
handleStationTimeRequest(ws, data);
break;
case 'request_list_txt':
break;
case 'heartbeat':
handleHeartbeat(ws, data);
break;
case 'station_schedule_sync':
handleScheduleSync(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 assignDeviceFields(target, data) {
if (!target || !data) return;
if (data.hasStationPeripheral !== undefined) target.hasStationPeripheral = Boolean(data.hasStationPeripheral);
if (data.stationPeripheralSide !== undefined) target.stationPeripheralSide = String(data.stationPeripheralSide || '');
if (data.hasBlockReader !== undefined) target.hasBlockReader = Boolean(data.hasBlockReader);
if (data.hasCreateTarget !== undefined) target.hasCreateTarget = Boolean(data.hasCreateTarget);
if (data.identityMode !== undefined) target.identityMode = String(data.identityMode || '');
if (data.upcomingTrains !== undefined) target.upcomingTrains = Array.isArray(data.upcomingTrains) ? data.upcomingTrains : [];
if (data.upcomingCount !== undefined) target.upcomingCount = Number(data.upcomingCount) || 0;
if (data.currentTrain !== undefined) target.currentTrain = data.currentTrain || null;
if (data.tickRate !== undefined) target.tickRate = Number(data.tickRate) || 20;
if (data.syncedAt !== undefined) target.syncedAt = Number(data.syncedAt) || Date.now();
}
function handleHeartbeat(ws, data) {
if (!data.stationName) return;
const cleanName = String(data.stationName || '').trim();
const before = stations[cleanName];
const result = upsertStationNode(cleanName, ws, data);
if (!result) return;
const { st, node } = result;
const wasNew = !before || before.nodes.length === 0 || (before.nodes.length === 1 && result && !before.nodes.find(n => n.wsId === node.wsId));
const tags = [];
if (node?.hasStationPeripheral) tags.push('HAS_STATION');
if (node?.hasBlockReader) tags.push('HAS_BLOCKREADER');
if (node?.hasCreateTarget) tags.push('HAS_TARGET');
if (wasNew) {
log('INFO', `Station auto-registered via heartbeat: "${cleanName}"${data.stationVersion ? ` ${data.stationVersion}` : ''}${tags.length ? ' [' + tags.join(',') + ']' : ''} nodeId=${node?.nodeId || ''}`);
}
maybeSendStationUpdate(ws, cleanName, data.stationVersion || st?.version);
if (wasNew || data.upcomingTrains || data.currentTrain || data.hasStationPeripheral !== undefined) {
sendStationGroupState(cleanName);
broadcastUpdate();
}
}
function handleScheduleSync(ws, data) {
if (!data.stationName) return;
const cleanName = String(data.stationName || '').trim();
const before = stations[cleanName];
const wasNew = !before;
const result = upsertStationNode(cleanName, ws, { ...data, syncedAt: Date.now() });
if (!result) return;
if (wasNew) {
log('INFO', `Station auto-registered via schedule sync: "${cleanName}" (upcoming: ${Number(data.upcomingCount) || 0}) nodeId=${result.node?.nodeId || ''}`);
}
sendStationGroupState(cleanName);
broadcastUpdate();
}
function handleStationRegister(ws, data) {
const { stationName, stationCode, stationPlatform, stationVersion } = data;
const cleanName = stationName.trim();
const result = upsertStationNode(cleanName, ws, data);
if (!result) return;
const { st, node } = result;
const cableGrp = getCableGroupByStationName(cleanName);
log('SUCCESS', `Station registered: "${cleanName}" (${stationCode})${stationVersion ? ` ${stationVersion}` : ''} nodeId=${node?.nodeId || ''}`);
ws.send(JSON.stringify({
type: 'station_register_ack',
stationName: cleanName,
nodeId: node?.nodeId || '',
audioCableGroup: cableGrp?.id || st?.audioCableGroup || ''
}));
maybeSendStationUpdate(ws, cleanName, stationVersion);
saveData();
sendStationGroupState(cleanName);
broadcastUpdate();
}
function getBaseName(name) {
if (!name) return "";
return String(name).toLowerCase()
.replace(/^[\d\w]+[-_][\d\w]+\s+/, '') // Strip leading code "01-01 "
.replace(/\s*platform\s*[\d\w]+/gi, '')
.replace(/\s*track\s*[\d\w]+/gi, '')
.replace(/\s*站台\s*[\d\w]+/gi, '')
.replace(/\s*股道\s*[\d\w]+/gi, '')
.replace(/@/g, ' ')
.replace(/\s*(station|站|tread|track)$/gi, '')
.replace(/[\(\[].*?[\)\]]/g, ' ')
.replace(/\s+\d+$/g, '')
.replace(/[^\w\s\u4e00-\u9fa5]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function isFuzzyMatch(str, pattern) {
if (!str || !pattern) return false;
const s = String(str).toLowerCase().trim();
const p = String(pattern).toLowerCase().trim();
if (s === p) return true;
// 1. Wildcard match
if (p.includes('*')) {
const regex = new RegExp('^' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*') + '$', 'i');
if (regex.test(s)) return true;
}
// 2. Base name comparison
const b1 = getBaseName(s);
const b2 = getBaseName(p);
if (b1 && b2 && b1 === b2) return true;
// 3. Substring match of base names (if one is long enough)
if (b1 && b2) {
if (b1.length >= 4 && b2.length >= 4) {
if (b1.includes(b2) || b2.includes(b1)) return true;
}
}
// 4. Stripped comparison
const s1 = s.replace(/[\s\-\._\(\)\[\]]/g, '');
const s2 = p.replace(/[\s\-\._\(\)\[\]]/g, '');
if (s1 && s2 && s1 === s2) return true;
// 5. Station code comparison
const c1 = getStationCodeFromName(s);
const c2 = getStationCodeFromName(p);
if (c1 && c2 && c1 === c2) {
// Only trust code match if it looks like a real code (contains numbers or is very short)
const isRealCode = (str) => /[\d]/.test(str) || str.length <= 4;
if (isRealCode(c1)) return true;
// If it's a long name-based code, also require base name similarity
if (b1 && b2 && (b1.includes(b2) || b2.includes(b1))) return true;
}
return false;
}
function findStationByFlexibleName(targetName) {
if (!targetName) return null;
// 1. Try exact match
if (stations[targetName]) return stations[targetName];
// 2. Search through registered stations using fuzzy match
for (const [regName, data] of Object.entries(stations)) {
if (isFuzzyMatch(regName, targetName)) {
// log('DEBUG', `[Station-Search] Matched "${targetName}" to registered "${regName}"`);
return data;
}
}
// Log available stations for debugging
const available = Object.keys(stations).map(k => `"${k}" (base: "${getBaseName(k)}")`).join(', ');
log('DEBUG', `[Station-Search] Could not find match for "${targetName}" (base: "${getBaseName(targetName)}"). Available: [${available}]`);
return null;
}
function handleTrainApproaching(ws, data) {
const trainId = data.trainId;
const stationName = getCorrectStationName(data.stationName);
const stationCode = data.stationCode;
const platform = data.stationPlatform || data.platform || '1';
const cleanPlatform = String(platform).replace(/\D/g, ''); // 提取数字部分
const finalPlatform = cleanPlatform || '1';
const lineCode = data.lineCode || (stationCode ? stationCode.split('-')[0] : 'UNK');
const destCode = data.destCode || getStationCodeFromName(data.destination) || 'UNK';
log('INFO', `Train ${trainId} approaching ${stationName}. Broadcasting pre-arrival...`);
ws.send(JSON.stringify({
type: 'train_approaching_ack',
success: true,
message: 'Train approaching processed successfully',
trainId
}));
const seq = ['ksts'];
seq.push('ja-fc1', `ja-${finalPlatform}`, 'ja-fc2', `ja-${destCode}`, 'ja-fc3');
seq.push('ch-fc1', `ch-${finalPlatform}`, 'ch-fc2', 'ch-fc3', `ch-${destCode}`, 'ch-fc4');
enqueueHlsPush(stationName, seq);
const audioId = `approaching-${stationCode}-${Date.now()}`;
const built = buildSequenceWavFile(seq);
if (built) {
log('INFO', `Pre-arrival audio queued for ${stationName}: ${audioId} (${built.durationSec.toFixed(2)}s)`);
enqueueStationAudioTask(stationName, ws, {
audioFormat: built.audioFormat,
sourcePath: built.sourcePath,
streamUrl: built.streamUrl,
durationSec: built.durationSec,
priority: 2, // Priority 2 for Approaching
interruptMode: 'ifLower',
source: 'train_approaching',
audioId,
consoleStreamStart: { type: 'stream_start', station: stationName, seq, streamName: getStationStreamName(stationName), hlsUrl: getStationHlsUrl(getStationStreamName(stationName)) },
consoleStreamEnd: { type: 'stream_end', station: stationName }
});
} else {
log('WARN', `Pre-arrival audio build returned empty for ${stationName}: ${audioId}`);
}
}
function handleTrainAlert(ws, data) {
const trainId = data.trainId;
const stationName = getCorrectStationName(data.stationName);
const stationCode = data.stationCode;
const platform = data.stationPlatform || data.platform || '1';
const lineCode = data.lineCode || (stationCode ? stationCode.split('-')[0] : 'UNK');
const delay = Math.max(0, Number(data.delay) || 0); // 获取延误信息(分钟)
const cancelled = Boolean(data.cancelled);
const rawReason = String(data.reason || '').trim();
log('INFO', `Train ${trainId} alert at ${stationName} (Delay: ${delay} min, Cancelled: ${cancelled}). Broadcasting alert...`);
ws.send(JSON.stringify({
type: 'train_alert_ack',
success: true,
trainId
}));
const seq = ['ksts'];
const normalizeReason = (reason, lang) => {
let r = String(reason || '').trim();
if (!r || r === 'delay') r = 'default_delay';
if (lang === 'ch' && r === 'train') r = 'delaytrain';
return r;
};
if (cancelled) {
seq.push(
'ja-cancel1', `ja-${lineCode}`, 'ja-cancel2',
'ch-cancel1', `ch-${lineCode}`, 'ch-cancel2'
);
} else if (delay > 0) {
const d = Math.max(1, Math.round(delay));
const jaReason = normalizeReason(rawReason, 'ja');
const chReason = normalizeReason(rawReason, 'ch');
seq.push(
'ja-delay1', `ja-${lineCode}`, `ja-${jaReason}`, 'ja-delay2', `ja-${d}min`, 'ja-delay3',
'ch-delay1', `ch-${lineCode}`, 'ch-delay2', `ch-${chReason}`, 'ch-delay3', `ch-${d}`, 'ch-delay4'
);
} else {
return;
}
enqueueHlsPush(stationName, seq);
const audioId = `alert-${stationCode}-${Date.now()}`;
const built = buildSequenceWavFile(seq);
if (built) {
log('INFO', `Alert audio queued for ${stationName}: ${audioId} (${built.durationSec.toFixed(2)}s)`);
enqueueStationAudioTask(stationName, ws, {
audioFormat: built.audioFormat,
sourcePath: built.sourcePath,
streamUrl: built.streamUrl,
durationSec: built.durationSec,
priority: 1, // Priority 1 for Alert
interruptMode: 'ifLower',
source: 'train_alert',
audioId,
consoleStreamStart: { type: 'stream_start', station: stationName, seq, streamName: getStationStreamName(stationName), hlsUrl: getStationHlsUrl(getStationStreamName(stationName)) },
consoleStreamEnd: { type: 'stream_end', station: stationName }
});
} else {
log('WARN', `Alert audio build returned empty for ${stationName}: ${audioId}`);
}
}
function handleTrainArrival(ws, data) {
const trainId = data.trainId;
const stationName = getCorrectStationName(data.stationName);
const stationCode = data.stationCode;
const platform = data.stationPlatform || data.platform || '1';
const cleanPlatform = String(platform).replace(/\D/g, ''); // 提取数字部分
const finalPlatform = cleanPlatform || '1';
const lineCode = data.lineCode || (stationCode ? stationCode.split('-')[0] : 'UNK');
const destCode = data.destCode || getStationCodeFromName(data.destination) || 'UNK';
const nextStationRaw = data.nextStation ? String(data.nextStation).trim() : '';
const nextCodeRaw = data.nextCode ? String(data.nextCode).trim() : (nextStationRaw ? getStationCodeFromName(nextStationRaw) : '');
const hasNextStation = nextStationRaw && nextStationRaw !== '' && nextCodeRaw && nextCodeRaw !== 'UNK' && nextCodeRaw !== '';
const finalNextCode = hasNextStation ? nextCodeRaw : null;
const existingIndex = activeTrains.findIndex(t => t.trainId === trainId);
const trainData = {
trainId,
stationName,
status: 'arrived',
lastUpdated: Date.now()
};
if (existingIndex >= 0) {
activeTrains[existingIndex] = trainData;
} else {
activeTrains.push(trainData);
}
log('INFO', `Train ${trainId} arrived at ${stationName}. Broadcasting... nextStation=${nextStationRaw || '(none/terminal)'} nextCode=${finalNextCode || '(none)'}`);
saveData();
broadcastUpdate();
const departureBeep = data.departureBeep || 'fc1';
const departureSeq = [departureBeep, 'ja-doorwarn', 'ch-doorwarn'];
const departureBuilt = buildSequenceWavFile(departureSeq);
const departureDuration = departureBuilt ? departureBuilt.durationSec : 10;
ws.send(JSON.stringify({
type: 'train_arrival_ack',
success: true,
message: 'Train arrival processed successfully',
trainId,
departureDuration
}));
// Broadcast Audio Logic
const seq = ['ksts'];
if (finalNextCode) {
seq.push(
`ja-${finalPlatform}`, 'ja-zt', `ja-${lineCode}`, `ja-${destCode}`, 'ja-to', 'ja-nextsta', `ja-${finalNextCode}`, 'ja-warn',
`ch-${finalPlatform}`, 'ch-zt', `ch-${lineCode}`, 'ch-to', `ch-${destCode}`, 'ch-nextsta', `ch-${finalNextCode}`, 'ch-warn'
);
} else {
seq.push(
`ja-${finalPlatform}`, 'ja-zt', `ja-${lineCode}`, `ja-${destCode}`, 'ja-warn',
`ch-${finalPlatform}`, 'ch-zt', `ch-${lineCode}`, 'ch-to', `ch-${destCode}`, 'ch-warn'
);
}
enqueueHlsPush(stationName, seq);
const audioId = `arrival-${stationCode}-${Date.now()}`;
const built = buildSequenceWavFile(seq);
if (built) {
enqueueStationAudioTask(stationName, ws, {
audioFormat: built.audioFormat,
sourcePath: built.sourcePath,
streamUrl: built.streamUrl,
durationSec: built.durationSec,
priority: 3,
interruptMode: 'all',
source: 'train_arrival',
audioId,
consoleStreamStart: { type: 'stream_start', station: stationName, seq, streamName: getStationStreamName(stationName), hlsUrl: getStationHlsUrl(getStationStreamName(stationName)) },
consoleStreamEnd: { type: 'stream_end', station: stationName }
});
}
}
function handleTrainDeparture(ws, data) {
const trainId = data.trainId;
const stationName = getCorrectStationName(data.stationName);
const stationCode = data.stationCode;
const platform = data.stationPlatform || data.platform || '1';
const cleanPlatform = String(platform).replace(/\D/g, '');
const finalPlatform = cleanPlatform || '1';
const beep = data.departureBeep || data.beep || 'fc1';
const lineCode = data.lineCode || (stationCode ? stationCode.split('-')[0] : 'UNK');
const destCode = data.destCode || getStationCodeFromName(data.destination) || 'UNK';
const nextStationRaw = data.nextStation ? String(data.nextStation).trim() : '';
const nextCodeRaw = data.nextCode ? String(data.nextCode).trim() : (nextStationRaw ? getStationCodeFromName(nextStationRaw) : '');
const hasNextStation = nextStationRaw && nextStationRaw !== '' && nextCodeRaw && nextCodeRaw !== 'UNK' && nextCodeRaw !== '';
const finalNextCode = hasNextStation ? nextCodeRaw : null;
const existingIndex = activeTrains.findIndex(t => t.trainId === trainId);
if (existingIndex >= 0) {
activeTrains.splice(existingIndex, 1);
}
log('INFO', `Train ${trainId} departing from ${stationName}. Broadcasting departure... nextStation=${nextStationRaw || '(none/terminal)'} nextCode=${finalNextCode || '(none)'}`);
saveData();
broadcastUpdate();
ws.send(JSON.stringify({
type: 'train_departure_ack',
success: true,
message: 'Train departure processed successfully',
trainId
}));
// Departure Audio Logic
const seq = [beep, 'ja-doorwarn', 'ch-doorwarn'];
if (finalNextCode) {
seq.push(
'ja-fc1', `ja-${finalPlatform}`, 'ja-fc2', `ja-${destCode}`, 'ja-fc3', 'ja-nextsta', `ja-${finalNextCode}`,
'ch-fc1', `ch-${finalPlatform}`, 'ch-fc2', 'ch-fc3', `ch-${destCode}`, 'ch-nextsta', `ch-${finalNextCode}`
);
} else {
seq.push(
'ja-fc1', `ja-${finalPlatform}`, 'ja-fc2', `ja-${destCode}`, 'ja-fc3',
'ch-fc1', `ch-${finalPlatform}`, 'ch-fc2', 'ch-fc3', `ch-${destCode}`
);
}
enqueueHlsPush(stationName, seq);
const audioId = `departure-${stationCode}-${Date.now()}`;
const built = buildSequenceWavFile(seq);
if (built) {
enqueueStationAudioTask(stationName, ws, {
audioFormat: built.audioFormat,
sourcePath: built.sourcePath,
streamUrl: built.streamUrl,
durationSec: built.durationSec,
priority: 3,
interruptMode: 'all',
source: 'train_departure',
audioId,
consoleStreamStart: { type: 'stream_start', station: stationName, seq, streamName: getStationStreamName(stationName), hlsUrl: getStationHlsUrl(getStationStreamName(stationName)) },
consoleStreamEnd: { type: 'stream_end', station: stationName }
});
}
}
function handleStationTimeRequest(ws, data) {
// Simple logic: default to 60s or lookup schedule
const stationTime = 60;
ws.send(JSON.stringify({
type: 'station_time_response',
trainId: data.trainId,
stationTime: stationTime
}));
}
function broadcastToConsole(msg) {
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN && client.isConsole) {
client.send(JSON.stringify(msg));
}
});
}
function broadcastUpdate() {
const list = getMergedStationList().sort((a, b) => a.name.localeCompare(b.name));
const state = {
type: 'state_update',
stations: list,
activeTrains,
stationUpdate: getStationUpdateSummary(),
audioCableGroups: Object.values(audioCableGroups)
};
broadcastToConsole(state);
}
app.get(STATION_SCRIPT_DOWNLOAD_PATH, (req, res) => {
if (!fs.existsSync(stationScriptPath)) {
return res.status(404).send('station_broadcast.lua not found');
}
return res.sendFile(stationScriptPath);
});
app.get(STATION_UPDATE_SCRIPT_DOWNLOAD_PATH, (req, res) => {
if (!fs.existsSync(stationUpdateScriptPath)) {
return res.status(404).send('update_station_broadcast.lua not found');
}
return res.sendFile(stationUpdateScriptPath);
});
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/remote-version', async (req, res) => {
try {
const response = await fetch('https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/branch/main/station_broadcast.lua');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const text = await response.text();
const match = text.match(/local\s+APP_VERSION_TEXT\s*=\s*["']([^"']+)["']/);
if (match) {
res.json({ success: true, version: String(match[1]).trim() });
} else {
res.json({ success: false, message: 'Version match failed' });
}
} catch (e) {
log('ERROR', `Failed to fetch remote version: ${e.message}`);
res.status(500).json({ success: false, message: e.message });
}
});
app.get('/api/version', (req, res) => {
res.json({ version: audioVersion });
});
app.get('/api/station-update', (req, res) => {
res.json({
success: true,
...getStationUpdateSummary(req)
});
});
app.post('/api/station-updates/publish', async (req, res) => {
const result = await publishStationUpdate(req);
res.json({
success: true,
...result
});
});
app.post('/api/publish-version', (req, res) => {
const version = normalizeVersionString(req.body?.version) || String(Date.now());
const snap = snapshotFiles();
const prev = versions.length > 0 ? versions[versions.length - 1].snapshot : null;
const changes = diffSnapshots(prev, snap);
const entry = {
id: String(Date.now()),
version,
note: normalizeVersionString(req.body?.note),
createdAt: Date.now(),
snapshot: snap,
changes
};
versions.push(entry);
audioVersion = version;
saveData();
log('INFO', `Audio version published: ${version}`);
res.json({ success: true, version });
});
app.get('/api/versions', (req, res) => {
const list = versions
.slice()
.reverse()
.map(v => ({
id: v.id,
version: v.version,
note: v.note || '',
createdAt: v.createdAt,
changes: v.changes || { added: [], modified: [], deleted: [] }
}));
res.json({ success: true, activeVersion: audioVersion, versions: list });
});
app.post('/api/versions', (req, res) => {
const version = normalizeVersionString(req.body?.version) || String(Date.now());
const snap = snapshotFiles();
const prev = versions.length > 0 ? versions[versions.length - 1].snapshot : null;
const changes = diffSnapshots(prev, snap);
const entry = {
id: String(Date.now()),
version,
note: normalizeVersionString(req.body?.note),
createdAt: Date.now(),
snapshot: snap,
changes
};
versions.push(entry);
audioVersion = version;
saveData();
log('INFO', `Audio version published: ${version}`);
res.json({ success: true, version, id: entry.id });
});
app.put('/api/versions/:id', (req, res) => {
const id = req.params.id;
const idx = versions.findIndex(v => v.id === id);
if (idx < 0) return res.status(404).json({ success: false, message: 'Version not found' });
const wasActive = versions[idx].version === audioVersion;
const nextVersion = normalizeVersionString(req.body?.version);
if (nextVersion) versions[idx].version = nextVersion;
const nextNote = normalizeVersionString(req.body?.note);
if (req.body?.note !== undefined) versions[idx].note = nextNote;
if (wasActive) audioVersion = versions[idx].version;
saveData();
res.json({ success: true });
});
app.get('/api/versions/:id/changes', (req, res) => {
const id = req.params.id;
const entry = versions.find(v => v.id === id);
if (!entry) return res.status(404).json({ success: false, message: 'Version not found' });
res.json({ success: true, changes: entry.changes || { added: [], modified: [], deleted: [] } });
});
app.get('/api/versions/:id/files', (req, res) => {
const id = req.params.id;
const entry = versions.find(v => v.id === id);
if (!entry) return res.status(404).json({ success: false, message: 'Version not found' });
res.json({ success: true, files: entry.snapshot || {} });
});
app.post('/api/save-data', (req, res) => {
saveData();
log('INFO', 'Manual data save triggered');
res.json({ success: true });
});
app.post('/api/upload/list', upload.single('listFile'), (req, res) => {
log('SUCCESS', 'Updated list.txt');
res.json({ success: true, message: 'list.txt updated' });
});
app.post('/api/upload/general-audio', upload.array('generalAudioFiles'), (req, res) => {
const files = req.files || [];
log('SUCCESS', `Uploaded ${files.length} beep audio files`);
res.json({ success: true, message: `${files.length} beep audio files uploaded` });
});
app.post('/api/upload/music-audio', upload.array('musicAudioFiles'), (req, res) => {
const files = req.files || [];
log('SUCCESS', `Uploaded ${files.length} music audio files`);
res.json({ success: true, message: `${files.length} music audio files uploaded` });
});
app.post('/api/upload/voice-audio', upload.array('voiceAudioFiles'), (req, res) => {
const files = req.files || [];
log('SUCCESS', `Uploaded ${files.length} voice audio files`);
res.json({ success: true, message: `${files.length} voice audio files uploaded` });
});
app.post('/api/upload/line-audio', upload.array('lineAudioFiles'), (req, res) => {
const files = req.files || [];
log('SUCCESS', `Uploaded ${files.length} line audio files`);
res.json({ success: true, message: `${files.length} line audio files uploaded` });
});
app.post('/api/upload/station-audio', upload.array('stationAudioFiles'), (req, res) => {
const files = req.files || [];
log('SUCCESS', `Uploaded ${files.length} station audio files`);
res.json({ success: true, message: `${files.length} station audio files uploaded` });
});
app.get('/api/files', (req, res) => {
res.json({ files: getManagedFiles() });
});
app.delete('/api/files', (req, res) => {
const filePath = req.query.path;
if (!filePath) return res.status(400).json({ success: false, message: 'Path required' });
const resolved = resolveManagedPath(filePath);
if (!resolved) return res.status(403).json({ success: false, message: 'Invalid path' });
const { fullPath, logicalPath } = resolved;
if (fs.existsSync(fullPath)) {
try {
fs.unlinkSync(fullPath);
log('INFO', `Deleted file: ${logicalPath}`);
// Also try to delete counterpart (.dfpwm if .wav deleted, or vice versa)
if (logicalPath.endsWith('.wav')) {
const dfpwmPath = fullPath.replace(/\.wav$/, '.dfpwm');
if (fs.existsSync(dfpwmPath)) fs.unlinkSync(dfpwmPath);
} else if (logicalPath.endsWith('.dfpwm')) {
const wavPath = fullPath.replace(/\.dfpwm$/, '.wav');
if (fs.existsSync(wavPath)) fs.unlinkSync(wavPath);
}
res.json({ success: true });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
} else {
res.status(404).json({ success: false, message: 'File not found' });
}
});
app.get('/api/stations', (req, res) => {
const list = getMergedStationList()
.sort((a, b) => String(a.code || '').localeCompare(String(b.code || '')) || String(a.name || '').localeCompare(String(b.name || '')));
res.json({ success: true, stations: list });
});
app.post('/api/stations', express.json(), (req, res) => {
const name = String(req.body?.name || '').trim();
const code = req.body?.code === undefined ? '' : String(req.body.code).trim();
if (!name) return res.status(400).json({ success: false, message: 'Station name required' });
if (stations[name]) return res.status(409).json({ success: false, message: 'Station already exists' });
stations[name] = {
name,
code,
platform: getPlatformNumberFromName(name),
audioCableGroup: '',
version: '',
lastSeen: Date.now(),
upcomingCount: 0,
upcomingTrains: [],
currentTrain: null,
nodes: []
};
normalizeStationRecord(stations[name]);
saveData();
broadcastUpdate();
res.json({ success: true, station: { name, code, lastSeen: stations[name].lastSeen, audioCableGroup: stations[name].audioCableGroup, nodeCount: 0 } });
});
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 };
if (!Array.isArray(stations[newName].nodes)) stations[newName].nodes = [];
if (stations[newName].audioCableGroup) {
const cgId = stations[newName].audioCableGroup;
if (audioCableGroups[cgId] && Array.isArray(audioCableGroups[cgId].stationNames)) {
const arr = audioCableGroups[cgId].stationNames;
const idx = arr.indexOf(name);
if (idx >= 0) arr[idx] = newName;
else if (!arr.includes(newName)) arr.push(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();
if (!Array.isArray(stations[targetName].nodes)) stations[targetName].nodes = [];
normalizeStationRecord(stations[targetName]);
saveData();
if (targetName !== name) sendStationGroupState(targetName);
broadcastUpdate();
res.json({ success: true, station: { name: stations[targetName].name, code: stations[targetName].code, lastSeen: stations[targetName].lastSeen, audioCableGroup: stations[targetName].audioCableGroup || '', nodeCount: stations[targetName].nodes.length } });
});
app.delete('/api/stations/:name', (req, res) => {
const selector = String(req.params.name || '').trim();
if (stations[selector]) {
delete stations[selector];
for (const id of Object.keys(audioCableGroups)) {
const g = audioCableGroups[id];
if (Array.isArray(g.stationNames)) {
g.stationNames = g.stationNames.filter(s => s !== selector);
}
}
log('INFO', `Deleted station: ${selector}`);
saveData();
broadcastUpdate();
res.json({ success: true });
} else {
res.status(404).json({ success: false, message: 'Station not found' });
}
});
app.put('/api/stations/:name/cable-group', express.json(), (req, res) => {
const name = String(req.params.name || '').trim();
const cableGroupId = String(req.body?.cableGroupId || '').trim();
if (!stations[name]) return res.status(404).json({ success: false, message: 'Station not found' });
const st = stations[name];
if (!cableGroupId) {
const oldId = st.audioCableGroup;
st.audioCableGroup = '';
if (oldId && audioCableGroups[oldId]) {
audioCableGroups[oldId].stationNames = (audioCableGroups[oldId].stationNames || []).filter(s => s !== name);
}
saveData();
sendStationGroupState(name);
broadcastUpdate();
return res.json({ success: true, station: { name, audioCableGroup: '' } });
}
if (!audioCableGroups[cableGroupId]) {
return res.status(404).json({ success: false, message: 'Cable group not found' });
}
const grp = audioCableGroups[cableGroupId];
const oldId = st.audioCableGroup;
if (oldId && oldId !== cableGroupId && audioCableGroups[oldId]) {
audioCableGroups[oldId].stationNames = (audioCableGroups[oldId].stationNames || []).filter(s => s !== name);
}
st.audioCableGroup = cableGroupId;
if (!Array.isArray(grp.stationNames)) grp.stationNames = [];
if (!grp.stationNames.includes(name)) grp.stationNames.push(name);
saveData();
sendStationGroupState(name);
broadcastUpdate();
res.json({ success: true, station: { name, audioCableGroup: cableGroupId } });
});
app.get('/api/audio-cable-groups', (req, res) => {
const list = Object.values(audioCableGroups).map(g => ({
id: g.id,
name: g.name || '',
color: g.color || '',
description: g.description || '',
stationNames: Array.isArray(g.stationNames) ? g.stationNames.slice() : [],
memberCount: Array.isArray(g.stationNames) ? g.stationNames.length : 0,
tokenHolder: (() => {
const tok = audioCableTokens.get(g.id);
if (!tok) return null;
return {
holderStationName: tok.holderStationName,
holderAudioId: tok.holderAudioId,
holderPriority: tok.holderPriority,
acquiredAt: tok.acquiredAt
};
})()
}));
res.json({ success: true, groups: list });
});
app.post('/api/audio-cable-groups', express.json(), (req, res) => {
const name = String(req.body?.name || '').trim();
const color = String(req.body?.color || '').trim();
const description = String(req.body?.description || '').trim();
const stationNamesRaw = req.body?.stationNames;
const stationNames = Array.isArray(stationNamesRaw)
? stationNamesRaw.map(s => String(s || '').trim()).filter(Boolean)
: [];
if (!name) return res.status(400).json({ success: false, message: 'Group name required' });
const id = 'cg_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6);
audioCableGroups[id] = {
id,
name,
color,
description,
stationNames: []
};
const validNames = [];
for (const sn of stationNames) {
if (!stations[sn]) continue;
stations[sn].audioCableGroup = id;
validNames.push(sn);
}
audioCableGroups[id].stationNames = validNames;
saveData();
for (const sn of validNames) sendStationGroupState(sn);
broadcastUpdate();
res.status(201).json({ success: true, group: audioCableGroups[id] });
});
app.put('/api/audio-cable-groups/:id', express.json(), (req, res) => {
const id = String(req.params.id || '').trim();
const grp = audioCableGroups[id];
if (!grp) return res.status(404).json({ success: false, message: 'Cable group not found' });
if (req.body?.name !== undefined) grp.name = String(req.body.name || '').trim();
if (req.body?.color !== undefined) grp.color = String(req.body.color || '').trim();
if (req.body?.description !== undefined) grp.description = String(req.body.description || '').trim();
const affected = new Set();
if (req.body?.stationNames !== undefined) {
const raw = Array.isArray(req.body.stationNames) ? req.body.stationNames : [];
const next = raw.map(s => String(s || '').trim()).filter(Boolean);
const prevArr = Array.isArray(grp.stationNames) ? grp.stationNames.slice() : [];
for (const oldName of prevArr) {
if (stations[oldName] && !next.includes(oldName)) {
stations[oldName].audioCableGroup = '';
affected.add(oldName);
}
}
const valid = [];
for (const sn of next) {
if (!stations[sn]) continue;
stations[sn].audioCableGroup = id;
valid.push(sn);
affected.add(sn);
}
grp.stationNames = valid;
}
saveData();
for (const n of Array.from(affected)) sendStationGroupState(n);
broadcastUpdate();
res.json({ success: true, group: grp });
});
app.delete('/api/audio-cable-groups/:id', (req, res) => {
const id = String(req.params.id || '').trim();
const grp = audioCableGroups[id];
if (!grp) return res.status(404).json({ success: false, message: 'Cable group not found' });
const affected = Array.isArray(grp.stationNames) ? grp.stationNames.slice() : [];
for (const sn of affected) {
if (stations[sn]) stations[sn].audioCableGroup = '';
}
const tok = audioCableTokens.get(id);
if (tok) {
if (tok.cancelToken) tok.cancelToken.cancelled = true;
audioCableTokens.delete(id);
}
delete audioCableGroups[id];
saveData();
for (const n of affected) sendStationGroupState(n);
broadcastUpdate();
res.json({ success: true });
});
app.post('/api/audio-cable-groups/:id/stations', express.json(), (req, res) => {
const id = String(req.params.id || '').trim();
const grp = audioCableGroups[id];
if (!grp) return res.status(404).json({ success: false, message: 'Cable group not found' });
const raw = Array.isArray(req.body?.stationNames) ? req.body.stationNames : [];
if (!Array.isArray(grp.stationNames)) grp.stationNames = [];
const added = [];
for (const s of raw) {
const sn = String(s || '').trim();
if (!sn || !stations[sn]) continue;
if (grp.stationNames.includes(sn)) continue;
const prev = stations[sn].audioCableGroup;
if (prev && prev !== id && audioCableGroups[prev]) {
audioCableGroups[prev].stationNames = (audioCableGroups[prev].stationNames || []).filter(x => x !== sn);
}
stations[sn].audioCableGroup = id;
grp.stationNames.push(sn);
added.push(sn);
}
saveData();
for (const n of added) sendStationGroupState(n);
broadcastUpdate();
res.json({ success: true, added, group: grp });
});
app.delete('/api/audio-cable-groups/:id/stations/:stationName', (req, res) => {
const id = String(req.params.id || '').trim();
const grp = audioCableGroups[id];
if (!grp) return res.status(404).json({ success: false, message: 'Cable group not found' });
const stationName = String(req.params.stationName || '').trim();
if (stations[stationName] && stations[stationName].audioCableGroup === id) {
stations[stationName].audioCableGroup = '';
}
if (Array.isArray(grp.stationNames)) {
grp.stationNames = grp.stationNames.filter(s => s !== stationName);
}
saveData();
sendStationGroupState(stationName);
broadcastUpdate();
res.json({ success: true });
});
app.delete('/api/streams/:streamName', (req, res) => {
const streamName = String(req.params.streamName || '').trim();
if (!streamName) return res.status(400).json({ success: false, message: 'Stream name required' });
const state = hlsStreams[streamName];
if (!state) return res.status(404).json({ success: false, message: 'Stream not found' });
if (state.currentProcess) {
try { state.currentProcess.kill('SIGKILL'); } catch {}
}
delete hlsStreams[streamName];
res.json({ success: true });
});
app.delete('/api/trains/:id', (req, res) => {
const id = req.params.id;
const initialLength = activeTrains.length;
activeTrains = activeTrains.filter(t => t.trainId !== id);
if (activeTrains.length < initialLength) {
log('INFO', `Deleted train: ${id}`);
saveData();
broadcastUpdate();
res.json({ success: true });
} else {
res.status(404).json({ success: false, message: 'Train not found' });
}
});
app.get('/api/files/content', (req, res) => {
const filePath = req.query.path;
if (!filePath) return res.status(400).json({ success: false, message: 'Path required' });
const resolved = resolveManagedPath(filePath);
if (!resolved) return res.status(403).json({ success: false, message: 'Invalid path' });
const { fullPath } = resolved;
if (fs.existsSync(fullPath)) {
try {
const content = fs.readFileSync(fullPath, 'utf8');
res.json({ success: true, content });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
} else {
res.status(404).json({ success: false, message: 'File not found' });
}
});
app.post('/api/files/content', express.json(), (req, res) => {
const { path: filePath, content } = req.body;
if (!filePath || content === undefined) return res.status(400).json({ success: false, message: 'Missing parameters' });
const resolved = resolveManagedPath(filePath);
if (!resolved) return res.status(403).json({ success: false, message: 'Invalid path' });
const { fullPath, logicalPath } = resolved;
try {
fs.writeFileSync(fullPath, content, 'utf8');
log('INFO', `Updated file: ${logicalPath}`);
res.json({ success: true });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
// TTS Generation Proxy
const tls = require('tls');
function requestViaProxy(urlStr, options, proxyUrlStr) {
return new Promise((resolve, reject) => {
const url = new URL(urlStr);
const proxyUrl = new URL(proxyUrlStr);
const proxyReq = http.request({
host: proxyUrl.hostname,
port: proxyUrl.port,
method: 'CONNECT',
path: url.hostname + ':' + (url.port || 443),
});
proxyReq.on('connect', (res, socket, head) => {
if (res.statusCode !== 200) {
return reject(new Error('Proxy connection failed: ' + res.statusCode));
}
const tlsSocket = tls.connect({
socket: socket,
servername: url.hostname
}, () => {
const req = require('https').request({
host: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
method: options.method || 'GET',
headers: options.headers,
createConnection: () => tlsSocket
}, (res) => {
let body = [];
res.on('data', chunk => body.push(chunk));
res.on('end', () => resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
buffer: Buffer.concat(body),
text: Buffer.concat(body).toString('utf8')
}));
});
req.on('error', reject);
if (options.body) req.write(options.body);
req.end();
});
tlsSocket.on('error', reject);
});
proxyReq.on('error', reject);
proxyReq.end();
});
}
function requestDirect(urlStr, options) {
return new Promise((resolve, reject) => {
const url = new URL(urlStr);
const req = require('https').request({
host: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
method: options.method || 'GET',
headers: options.headers
}, (res) => {
let body = [];
res.on('data', chunk => body.push(chunk));
res.on('end', () => resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
buffer: Buffer.concat(body),
text: Buffer.concat(body).toString('utf8')
}));
});
req.on('error', reject);
if (options.body) req.write(options.body);
req.end();
});
}
async function generateTtsWavToTemp(text, reference_id, model = DEFAULT_TTS_MODEL) {
let proxyUrl = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy;
if (!proxyUrl) {
proxyUrl = 'http://127.0.0.1:7890';
}
const requestBody = JSON.stringify({
text: text,
reference_id: reference_id,
model: model,
format: "wav"
});
const options = {
method: 'POST',
headers: {
'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestBody)
},
body: requestBody
};
const response = proxyUrl
? await requestViaProxy('https://api.fish.audio/v1/tts', options, proxyUrl)
: await requestDirect('https://api.fish.audio/v1/tts', options);
if (!response.ok) {
const msg = (response.text && String(response.text).trim().length > 0)
? response.text
: `Upstream API error (HTTP ${response.status})`;
const err = new Error(msg);
err.status = response.status || 500;
throw err;
}
const tempName = `temp_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
const tempPath = path.join(uploadDir, tempName);
fs.writeFileSync(tempPath, response.buffer);
return { tempName, tempPath, url: `/uploads/${tempName}` };
}
app.post('/api/tts/upload_temp', upload.single('audio'), (req, res) => {
try {
if (!req.file) return res.status(400).json({ success: false, message: 'No audio file uploaded' });
const tempName = `temp_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
const tempPath = path.join(uploadDir, tempName);
fs.renameSync(req.file.path, tempPath);
res.json({
success: true,
tempName: tempName,
url: `/uploads/${tempName}`
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
app.post('/api/tts/generate', express.json(), async (req, res) => {
const text = String(req.body?.text || '').trim();
const reference_id = String(req.body?.reference_id || '').trim();
const model = String(req.body?.model || DEFAULT_TTS_MODEL).trim();
if (!text || !reference_id) return res.status(400).json({ success: false, message: 'Missing text or reference_id' });
if (!model) return res.status(400).json({ success: false, message: 'Missing model' });
try {
let proxyUrl = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy;
console.log(`[TTS] Sending request for text: "${text}" with model "${model}"${proxyUrl ? ` via proxy: ${proxyUrl}` : ''}`);
const requestBody = JSON.stringify({
text: text,
reference_id: reference_id,
model: model,
format: "wav"
});
const options = {
method: 'POST',
headers: {
'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestBody)
},
body: requestBody
};
// Always try to use proxy
if (!proxyUrl) {
proxyUrl = 'http://127.0.0.1:7890';
}
let response;
try {
response = proxyUrl
? await requestViaProxy('https://api.fish.audio/v1/tts', options, proxyUrl)
: await requestDirect('https://api.fish.audio/v1/tts', options);
} catch (e) {
// Fallback if proxy fails
if (proxyUrl === 'http://127.0.0.1:7890') {
console.log(`[TTS] Local proxy failed, attempting direct connection...`);
response = await requestDirect('https://api.fish.audio/v1/tts', options);
} else {
throw e;
}
}
if (!response.ok) {
const msg = (response.text && String(response.text).trim().length > 0)
? response.text
: `Upstream API error (HTTP ${response.status})`;
console.error(`[TTS] API Error Status ${response.status}: ${msg}`);
return res.status(response.status || 500).json({ success: false, message: msg });
}
// Save to a temporary file
const tempName = `temp_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
const tempPath = path.join(uploadDir, tempName);
fs.writeFileSync(tempPath, response.buffer);
console.log(`[TTS] Success: saved to ${tempName}`);
res.json({ success: true, url: `/uploads/${tempName}`, tempName });
} catch (e) {
console.error("[TTS] Generation Error:", e);
const msg = (e && e.message && String(e.message).trim().length > 0)
? e.message
: (e && e.code ? String(e.code) : 'Unknown error');
res.status(500).json({ success: false, message: msg + (e && e.cause && e.cause.message ? ` (${e.cause.message})` : '') });
}
});
// Endpoint to receive temp files from client
app.post('/api/tts/upload_temp', upload.fields([{ name: 'zhAudio', maxCount: 1 }, { name: 'enAudio', maxCount: 1 }]), (req, res) => {
try {
const zhFile = req.files['zhAudio'] ? req.files['zhAudio'][0] : null;
const enFile = req.files['enAudio'] ? req.files['enAudio'][0] : null;
if (!zhFile || !enFile) {
return res.status(400).json({ success: false, message: 'Missing audio files' });
}
// Rename to secure temp names
const zhTempName = `temp_zh_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
const jaTempName = `temp_ja_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
const zhTempPath = path.join(uploadDir, zhTempName);
const jaTempPath = path.join(uploadDir, jaTempName);
fs.renameSync(zhFile.path, zhTempPath);
fs.renameSync(enFile.path, jaTempPath);
res.json({
success: true,
zhTempName,
jaTempName,
zhUrl: `/uploads/${zhTempName}`,
jaUrl: `/uploads/${jaTempName}`,
// Keep old keys temporarily so cached frontends do not break.
enTempName: jaTempName,
enUrl: `/uploads/${jaTempName}`
});
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
function sanitizeStationCode(code) {
let s = String(code || '').trim();
s = s.replace(/\\/g, '/');
s = s.replace(/\.\./g, '');
s = s.split('/').map(part => part.replace(/[<>:"|?*]/g, '').trim()).filter(Boolean).join('/');
return s;
}
function ensureDirForFile(filePath) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
// Import TTS temp files to station folder
app.post('/api/tts/import', express.json(), (req, res) => {
const stationCode = String(req.body?.stationCode || '').trim();
const type = String(req.body?.type || 'station').trim();
const zhTemp = String(req.body?.zhTemp || '').trim();
const jaTemp = String(req.body?.jaTemp || req.body?.enTemp || '').trim();
if (!stationCode || !zhTemp || !jaTemp) return res.status(400).json({ success: false, message: 'Missing parameters' });
const zhSource = path.join(uploadDir, zhTemp);
const jaSource = path.join(uploadDir, jaTemp);
const safeStationCode = sanitizeStationCode(stationCode);
if (!safeStationCode) return res.status(400).json({ success: false, message: 'Invalid station code' });
const targetDir = type === 'line' ? audioLineDir : audioStationDir;
const zhDest = path.join(targetDir, `ch-${safeStationCode}.wav`);
const jaDest = path.join(targetDir, `ja-${safeStationCode}.wav`);
try {
ensureDirForFile(zhDest);
ensureDirForFile(jaDest);
if (fs.existsSync(zhSource)) {
fs.copyFileSync(zhSource, zhDest);
fs.unlinkSync(zhSource);
}
if (fs.existsSync(jaSource)) {
fs.copyFileSync(jaSource, jaDest);
fs.unlinkSync(jaSource);
}
log('SUCCESS', `Imported generated audio for station ${stationCode}`);
res.json({ success: true });
} catch (e) {
log('ERROR', `Failed to import audio: ${e.message}`);
res.status(500).json({ success: false, message: e.message });
}
});
function safeFilename(name) {
const base = path.basename(String(name || '').trim());
if (!base || base.includes('/') || base.includes('\\')) return null;
return base;
}
function findWavByBaseName(baseName) {
const safeBase = String(baseName || '').trim().replace(/\.wav$/i, '');
if (!safeBase) return null;
const filename = `${safeBase}.wav`;
const searchDirs = [
...getBeepAudioDirs(),
audioStationDir,
audioLineDir,
uploadDir
];
for (const dir of searchDirs) {
const direct = path.join(dir, filename);
if (fs.existsSync(direct)) return direct;
const found = findFileCaseInsensitive(dir, filename);
if (found) return found;
}
return null;
}
function buildTempBroadcastFromWav(prefixWavPath, contentWavPath) {
const broadcastId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const outBase = `temp_${broadcastId}`;
const outWavPath = path.join(tempBroadcastDir, `${outBase}.wav`);
const filterComplex = [
'[0:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a0]',
'[1:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a1]',
'[a0][a1]concat=n=2:v=0:a=1[a]'
].join(';');
execSync(`ffmpeg -hide_banner -loglevel error -y -i "${prefixWavPath}" -i "${contentWavPath}" -filter_complex "${filterComplex}" -map "[a]" -ac 1 -ar 48000 -c:a pcm_s16le "${outWavPath}"`);
const wavBuf = fs.readFileSync(outWavPath);
const durationSec = getWavDurationSec(wavBuf) || 0;
return {
broadcastId,
wavPath: outWavPath,
durationSec
};
}
app.post('/api/temp-broadcast/build', (req, res) => {
const contentTempName = safeFilename(req.body?.contentTempName);
const prefixName = String(req.body?.prefixName || 'lsts').trim();
if (!contentTempName) return res.status(400).json({ success: false, message: 'Invalid contentTempName' });
const contentPath = path.join(uploadDir, contentTempName);
if (!contentPath.startsWith(uploadDir)) return res.status(403).json({ success: false, message: 'Invalid path' });
if (!fs.existsSync(contentPath)) return res.status(404).json({ success: false, message: 'Content temp wav not found' });
const prefixWavPath = findWavByBaseName(prefixName);
if (!prefixWavPath) return res.status(404).json({ success: false, message: `Prefix wav not found: ${prefixName}.wav` });
try {
const built = buildTempBroadcastFromWav(prefixWavPath, contentPath);
res.json({
success: true,
broadcastId: built.broadcastId,
wavUrl: `/media/temp/${path.basename(built.wavPath)}`,
durationSec: built.durationSec
});
} catch (e) {
res.status(500).json({ success: false, message: e?.message || String(e) });
}
});
app.post('/api/temp-broadcast/compose', express.json(), async (req, res) => {
const text = String(req.body?.text || '').trim();
const reference_id = String(req.body?.reference_id || DEFAULT_ZH_REFERENCE_ID).trim();
const model = String(req.body?.model || DEFAULT_TTS_MODEL).trim();
const prefixName = String(req.body?.prefixName || 'lsts').trim();
if (!text) return res.status(400).json({ success: false, message: 'text required' });
if (!reference_id) return res.status(400).json({ success: false, message: 'reference_id required' });
if (!model) return res.status(400).json({ success: false, message: 'model required' });
const prefixWavPath = findWavByBaseName(prefixName);
if (!prefixWavPath) return res.status(404).json({ success: false, message: `Prefix wav not found: ${prefixName}.wav` });
let ttsTempPath = null;
try {
const tts = await generateTtsWavToTemp(text, reference_id, model);
ttsTempPath = tts.tempPath;
const built = buildTempBroadcastFromWav(prefixWavPath, ttsTempPath);
try { fs.unlinkSync(ttsTempPath); } catch {}
ttsTempPath = null;
res.json({
success: true,
broadcastId: built.broadcastId,
wavUrl: `/media/temp/${path.basename(built.wavPath)}`,
durationSec: built.durationSec
});
} catch (e) {
if (ttsTempPath) {
try { fs.unlinkSync(ttsTempPath); } catch {}
}
res.status(Number(e?.status) || 500).json({ success: false, message: e?.message || String(e) });
}
});
app.post('/api/temp-broadcast/publish', (req, res) => {
const broadcastId = String(req.body?.broadcastId || '').trim();
const urgency = String(req.body?.urgency || 'normal').trim().toLowerCase();
const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : [];
if (!broadcastId) return res.status(400).json({ success: false, message: 'broadcastId required' });
if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' });
if (urgency !== 'normal' && urgency !== 'emergency') return res.status(400).json({ success: false, message: 'Invalid urgency' });
const wavPath = path.join(tempBroadcastDir, `temp_${broadcastId}.wav`);
if (!fs.existsSync(wavPath)) return res.status(404).json({ success: false, message: 'Temp broadcast not found (wav)' });
const wavBuf = fs.readFileSync(wavPath);
const durationSec = getWavDurationSec(wavBuf) || 0;
const streamUrl = getManagedMediaUrl('temp', path.basename(wavPath));
const priority = urgency === 'emergency' ? 2 : 0;
const interruptMode = urgency === 'emergency' ? 'all' : 'none';
const source = urgency === 'emergency' ? 'temp_emergency' : 'temp_normal';
const requeueOnPreempt = urgency !== 'emergency';
let sent = 0;
let failed = 0;
const results = [];
for (const group of expandStationTargets(stationsReq)) {
const onlineMembers = group.memberNames.filter(memberName => {
const ws = getStationWs(memberName);
return !!ws && ws.readyState === WebSocket.OPEN;
});
if (onlineMembers.length === 0) {
failed++;
results.push({ stationName: group.name, ok: false, reason: 'offline', memberCount: group.memberCount });
continue;
}
for (let idx = 0; idx < onlineMembers.length; idx++) {
const memberName = onlineMembers[idx];
const ws = getStationWs(memberName);
enqueueStationAudioTask(memberName, ws, {
audioFormat: 'wav-stream-live',
sourcePath: wavPath,
streamUrl,
durationSec,
priority,
interruptMode,
source,
audioId: `temp-${broadcastId}-${memberName}-${Date.now()}`,
requeueOnPreempt,
consoleStreamStart: idx === 0 ? { type: 'stream_start', station: group.name, seq: [`TEMP:${broadcastId}`], streamName: getStationStreamName(memberName), hlsUrl: getStationHlsUrl(getStationStreamName(memberName)) } : null,
consoleStreamEnd: idx === 0 ? { type: 'stream_end', station: group.name } : null
});
}
sent++;
results.push({ stationName: group.name, ok: true, memberCount: onlineMembers.length, totalMembers: group.memberCount });
}
res.json({ success: true, sent, failed, results });
});
app.get('/api/music/tracks', (req, res) => {
res.json({ success: true, tracks: listMusicTracks() });
});
app.post('/api/music/start', express.json(), (req, res) => {
const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : [];
const track = normalizeBaseName(req.body?.track);
const loop = Boolean(req.body?.loop);
if (!track) return res.status(400).json({ success: false, message: 'track required' });
if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' });
let playbackAudio;
try {
playbackAudio = getPlaybackAudioByBaseName(track);
} catch (e) {
return res.status(404).json({ success: false, message: e?.message || String(e) });
}
let sent = 0;
let failed = 0;
const results = [];
for (const group of expandStationTargets(stationsReq)) {
const onlineMembers = group.memberNames.filter(memberName => {
const ws = getStationWs(memberName);
return !!ws && ws.readyState === WebSocket.OPEN;
});
if (onlineMembers.length === 0) {
failed++;
results.push({ stationName: group.name, ok: false, reason: 'offline', memberCount: group.memberCount });
continue;
}
for (let idx = 0; idx < onlineMembers.length; idx++) {
const memberName = onlineMembers[idx];
const ws = getStationWs(memberName);
stopStationMusic(memberName, ws);
stationMusicStates.set(memberName, { enabled: true, track, loop });
const streamName = getStationStreamName(memberName);
const hlsUrl = HLS_OUTPUT_MODE === 'local'
? getLocalHlsUrlForRequest(streamName, req)
: getStationHlsUrlForRequest(streamName, req);
const enqueueOnce = () => {
const currentWs = getStationWs(memberName);
if (!currentWs || currentWs.readyState !== WebSocket.OPEN) {
setTimeout(enqueueOnce, 5000);
return;
}
if (playbackAudio.sourcePath) {
enqueueStationAudioTask(memberName, currentWs, {
audioFormat: 'wav-stream-live',
sourcePath: playbackAudio.sourcePath,
durationSec: playbackAudio.durationSec,
priority: 0,
interruptMode: 'none',
source: 'music',
loop,
audioId: `music-${track}-${memberName}-${Date.now()}`,
requeueOnPreempt: true,
consoleStreamStart: idx === 0 ? { type: 'stream_start', station: group.name, seq: [`MUSIC:${track}`], streamName, hlsUrl } : null,
consoleStreamEnd: idx === 0 ? { type: 'stream_end', station: group.name } : null
});
return;
}
enqueueStationAudioTask(memberName, currentWs, {
audioFormat: playbackAudio.audioFormat,
buffer: playbackAudio.buffer,
streamUrl: playbackAudio.streamUrl,
durationSec: playbackAudio.durationSec,
priority: 0,
interruptMode: 'none',
source: 'music',
loop,
audioId: `music-${track}-${memberName}-${Date.now()}`,
requeueOnPreempt: true,
consoleStreamStart: idx === 0 ? { type: 'stream_start', station: group.name, seq: [`MUSIC:${track}`], streamName, hlsUrl } : null,
consoleStreamEnd: idx === 0 ? { type: 'stream_end', station: group.name } : null
});
};
enqueueOnce();
}
sent++;
results.push({ stationName: group.name, ok: true, memberCount: onlineMembers.length, totalMembers: group.memberCount });
}
res.json({ success: true, sent, failed, results });
});
app.post('/api/music/stop', express.json(), (req, res) => {
const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : [];
if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' });
let stopped = 0;
const results = [];
for (const group of expandStationTargets(stationsReq)) {
for (const memberName of group.memberNames) {
const ws = getStationWs(memberName);
stationMusicStates.set(memberName, { enabled: false, track: null, loop: false });
stopStationMusic(memberName, ws);
}
stopped++;
results.push({ stationName: group.name, ok: true, memberCount: group.memberCount });
}
res.json({ success: true, stopped, results });
});
// Error handling middleware
app.use((err, req, res, next) => {
log('ERROR', `Unhandled error: ${err.message}`);
res.status(err.status || 500).json({
success: false,
message: err.message || 'Internal Server Error'
});
});
server.listen(PORT, () => {
loadData();
if (!audioVersion) audioVersion = String(Date.now());
if (!Array.isArray(versions) || versions.length === 0) {
const snap = snapshotFiles();
const changes = diffSnapshots(null, snap);
versions = [{
id: String(Date.now()),
version: audioVersion,
note: '',
createdAt: Date.now(),
snapshot: snap,
changes
}];
saveData();
}
console.log(`Cloud Server running on port ${PORT}`);
});