根因: getStationGroups (L881) + resolveStationGroup 单站分支 (L942) 错误地把 memberNames = nodes.map(n => n.nodeId || '') 填成了 nodeId 数组 (n_msx5ieg9_9iy1dw 这类), 但下游 4 处消费方 /api/music/start, /api/music/stop, /api/temp-broadcast/publish 全部把 memberNames[i] 当 stationName 用: getStationWs(memberName) → stations[n_xxx] = undefined → null stopStationMusic(memberName) → 按错 key 清队列 stationMusicStates.set(memberName) → 按错 key 存状态 enqueueStationAudioTask(memberName) → 按错 station 名入队 后果: 所有成员永远被判为 offline (reason=offline), 所以 API 返回 sent=0 failed=1, 音乐和临时广播 100% 无法触发推流, 端到端链路完全中断. 修法: 对 CC station group 语义来说 memberNames = 该 group 包含的每个子站的 stationName 数组 (同 cable group 的 stationNames 语义). 单站 selector 只包含 一个成员 [st.name], 无论这个站挂了多少个 node 连接, 最终 enqueue 都是按 stationName 维度合并到同一个队列, 不会重复推多次. 端到端验证通过: MUSIC START: sent=1 failed=0 ✓ TEMP PUBLISH: success=true sent=1 failed=0 ✓ Live WAV chunk stream start/end 两条日志完整闭合, 说明 ffmpeg 分块 → WS 推送 → 结束 全链路走通.
4440 lines
166 KiB
JavaScript
4440 lines
166 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) => {
|
|
if (req.method && String(req.method).toUpperCase() !== 'GET') return next();
|
|
const dirs = getBeepAudioDirs();
|
|
const rel = decodeURIComponent(String(req.path || '/').replace(/^\/+/, ''));
|
|
let i = 0;
|
|
const tryNext = () => {
|
|
if (i >= dirs.length) {
|
|
const candidates = (dirs || []).map(dir => safeRelativeToBase(dir, rel)).filter(Boolean);
|
|
if (candidates && candidates.length) return sendBinaryFileFromCandidates(res, candidates);
|
|
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) => {
|
|
if (req.method && String(req.method).toUpperCase() !== 'GET') return next();
|
|
const dirs = getBeepAudioDirs();
|
|
const rel = decodeURIComponent(String(req.path || '/').replace(/^\/+/, ''));
|
|
let i = 0;
|
|
const tryNext = () => {
|
|
if (i >= dirs.length) {
|
|
const candidates = (dirs || []).map(dir => safeRelativeToBase(dir, rel)).filter(Boolean);
|
|
if (candidates && candidates.length) return sendBinaryFileFromCandidates(res, candidates);
|
|
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', (req, res, next) => {
|
|
if (req.method && String(req.method).toUpperCase() !== 'GET') return next();
|
|
const rel = decodeURIComponent(String(req.path || '/').replace(/^\/+/, ''));
|
|
const base = String(audioMusicDir || '');
|
|
express.static(base, { fallthrough: true })(req, res, () => {
|
|
const full = base ? safeRelativeToBase(base, rel) : null;
|
|
if (!full) return res.status(404).end();
|
|
sendBinaryFile(res, full);
|
|
});
|
|
});
|
|
app.use('/media/audio/voice', (req, res, next) => {
|
|
if (req.method && String(req.method).toUpperCase() !== 'GET') return next();
|
|
const rel = decodeURIComponent(String(req.path || '/').replace(/^\/+/, ''));
|
|
const base = String(audioVoiceDir || '');
|
|
express.static(base, { fallthrough: true })(req, res, () => {
|
|
const full = base ? safeRelativeToBase(base, rel) : null;
|
|
if (!full) return res.status(404).end();
|
|
sendBinaryFile(res, full);
|
|
});
|
|
});
|
|
app.use('/media/audio/line', (req, res, next) => {
|
|
if (req.method && String(req.method).toUpperCase() !== 'GET') return next();
|
|
const rel = decodeURIComponent(String(req.path || '/').replace(/^\/+/, ''));
|
|
const base = String(audioLineDir || '');
|
|
express.static(base, { fallthrough: true })(req, res, () => {
|
|
const full = base ? safeRelativeToBase(base, rel) : null;
|
|
if (!full) return res.status(404).end();
|
|
sendBinaryFile(res, full);
|
|
});
|
|
});
|
|
app.use('/media/audio/station', (req, res, next) => {
|
|
if (req.method && String(req.method).toUpperCase() !== 'GET') return next();
|
|
const rel = decodeURIComponent(String(req.path || '/').replace(/^\/+/, ''));
|
|
const base = String(audioStationDir || '');
|
|
express.static(base, { fallthrough: true })(req, res, () => {
|
|
const full = base ? safeRelativeToBase(base, rel) : null;
|
|
if (!full) return res.status(404).end();
|
|
sendBinaryFile(res, full);
|
|
});
|
|
});
|
|
app.use('/media/temp', (req, res, next) => {
|
|
if (req.method && String(req.method).toUpperCase() !== 'GET') return next();
|
|
const rel = decodeURIComponent(String(req.path || '/').replace(/^\/+/, ''));
|
|
const base = String(tempBroadcastDir || '');
|
|
express.static(base, { fallthrough: true })(req, res, () => {
|
|
const full = base ? safeRelativeToBase(base, rel) : null;
|
|
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 = [st.name];
|
|
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: [st.name],
|
|
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 listFileBasenamesLower(dir) {
|
|
try {
|
|
const files = fs.readdirSync(dir, { withFileTypes: true });
|
|
const m = new Map();
|
|
for (const ent of files) {
|
|
if (!ent.isFile()) continue;
|
|
const full = path.join(dir, ent.name);
|
|
const lower = ent.name.toLowerCase();
|
|
m.set(lower, full);
|
|
const stem = lower.replace(/\.[^.]+$/, '');
|
|
if (!m.has(stem)) m.set(stem, full);
|
|
// Also map hyphen/underscore/space variants
|
|
const stemNoDash = stem.replace(/[\s\-_]+/g, '');
|
|
if (stemNoDash !== stem && !m.has(stemNoDash)) m.set(stemNoDash, full);
|
|
const stemDash = stem.replace(/[\s_]+/g, '-');
|
|
if (stemDash !== stem && !m.has(stemDash)) m.set(stemDash, full);
|
|
const stemUnder = stem.replace(/[\s\-]+/g, '_');
|
|
if (stemUnder !== stem && !m.has(stemUnder)) m.set(stemUnder, full);
|
|
}
|
|
return m;
|
|
} catch {
|
|
return new Map();
|
|
}
|
|
}
|
|
|
|
const _audioFileIndexCache = { version: 0, byDir: new Map() };
|
|
function getAudioDirIndex(dir) {
|
|
if (!dir || !fs.existsSync(dir)) return new Map();
|
|
let stat;
|
|
try { stat = fs.statSync(dir); } catch { return new Map(); }
|
|
const key = `${dir}|${stat.mtimeMs}|${stat.size}`;
|
|
const cached = _audioFileIndexCache.byDir.get(key);
|
|
if (cached) return cached;
|
|
const built = listFileBasenamesLower(dir);
|
|
_audioFileIndexCache.byDir.set(key, built);
|
|
if (_audioFileIndexCache.byDir.size > 100) _audioFileIndexCache.byDir.clear();
|
|
return built;
|
|
}
|
|
|
|
function findAudioFileSmart(dirs, tokenBase, ext) {
|
|
const wantExt = (ext || '.wav').toLowerCase();
|
|
const cleaned = String(tokenBase || '').trim();
|
|
if (!cleaned) return null;
|
|
|
|
const lower = cleaned.toLowerCase();
|
|
const lowerStem = lower.replace(/\.[^.]+$/, '');
|
|
const variants = new Set();
|
|
const baseSeeds = new Set([
|
|
lowerStem,
|
|
lowerStem.replace(/[\s_]+/g, '-'),
|
|
lowerStem.replace(/[\s\-]+/g, '_'),
|
|
lowerStem.replace(/[\s\-_]+/g, '')
|
|
]);
|
|
for (const s of Array.from(baseSeeds)) {
|
|
const mLang = s.match(/^([a-z]{2})-(.+)$/);
|
|
if (mLang) {
|
|
const L = mLang[1];
|
|
const inner = mLang[2];
|
|
baseSeeds.add(inner); // strip lang prefix
|
|
baseSeeds.add(`${L}${inner.replace(/[\-_]+/g, '')}`); // lang + dashless-inner (chce14)
|
|
const compactInner = inner.replace(/[\-_]+/g, '');
|
|
if (compactInner !== inner) {
|
|
baseSeeds.add(`${L}-${compactInner}`); // ch-ce14 from ch-ce-14
|
|
baseSeeds.add(`${L}_${compactInner}`);
|
|
}
|
|
const m2 = inner.match(/^([a-z]+)[\-_](.+)$/);
|
|
if (m2) {
|
|
baseSeeds.add(`${L}-${m2[2]}`); // ja-13 from ja-ce-13
|
|
baseSeeds.add(`${L}_${m2[2]}`);
|
|
baseSeeds.add(m2[2]); // 13
|
|
baseSeeds.add(inner.replace(/[\-_]+/g, '')); // ce13
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const b of baseSeeds) {
|
|
variants.add(`${b}${wantExt}`);
|
|
variants.add(b);
|
|
// Common prefix variants
|
|
const prefixes = ['station-', 'line-', 'voice-', 's-', 'l-', 'v-', 'stn-'];
|
|
for (const p of prefixes) {
|
|
variants.add(`${p}${b}${wantExt}`);
|
|
variants.add(`${p}${b}`);
|
|
}
|
|
}
|
|
|
|
for (const dir of dirs) {
|
|
if (!dir || !fs.existsSync(dir)) continue;
|
|
const idx = getAudioDirIndex(dir);
|
|
if (idx.size === 0) continue;
|
|
for (const v of variants) {
|
|
const found = idx.get(v);
|
|
if (found && fs.existsSync(found)) return found;
|
|
}
|
|
}
|
|
|
|
for (const dir of dirs) {
|
|
const direct = findFileCaseInsensitive(dir, `${lowerStem}${wantExt}`);
|
|
if (direct) return direct;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveStationAudioCode(rawCode, rawName) {
|
|
const code1 = String(rawCode || '').trim().toLowerCase().replace(/_/g, '-');
|
|
const name = String(rawName || '').trim();
|
|
const pick = (c) => {
|
|
if (!c) return null;
|
|
const v = String(c).trim().toLowerCase().replace(/_/g, '-');
|
|
return v && v !== 'unk' && v !== 'unknown' ? v : null;
|
|
};
|
|
let result = pick(code1);
|
|
let levelHit = result ? 1 : 0;
|
|
if (!result && name) {
|
|
const extracted = getStationCodeFromName(name);
|
|
result = pick(extracted);
|
|
if (result) levelHit = 2;
|
|
}
|
|
if (!result && name) {
|
|
const reg = findStationByFlexibleName(name);
|
|
if (reg && reg.code) {
|
|
result = pick(reg.code);
|
|
if (result) levelHit = 3;
|
|
}
|
|
}
|
|
if (result && (name || rawCode)) {
|
|
log('DEBUG', `[Station-Audio-Code] resolved destCode=${result} (level=${levelHit}) rawCode=${JSON.stringify(rawCode || null)} name=${JSON.stringify(name || null)}`);
|
|
} else {
|
|
log('WARN', `[Station-Audio-Code] FAILED to resolve station audio code: rawCode=${JSON.stringify(rawCode || null)} name=${JSON.stringify(name || null)} → sequence will skip this station audio (silence / missing wav)`);
|
|
}
|
|
return result || null;
|
|
}
|
|
|
|
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, label) {
|
|
ensureSilenceWav();
|
|
const dirsToSearch = [
|
|
audioStationDir,
|
|
audioLineDir,
|
|
audioVoiceDir,
|
|
...getBeepAudioDirs()
|
|
];
|
|
const files = [];
|
|
const missing = [];
|
|
const resolved = [];
|
|
|
|
for (const item of seq) {
|
|
if (item === '__DELAY_1__') {
|
|
files.push(silence1sWavPath);
|
|
resolved.push(`${item}=<silence>`);
|
|
continue;
|
|
}
|
|
|
|
const found = findAudioFileSmart(dirsToSearch, item, '.wav');
|
|
|
|
if (found) {
|
|
files.push(found);
|
|
resolved.push(`${item}=${found}`);
|
|
} else {
|
|
const dirList = dirsToSearch.filter(Boolean).join(', ');
|
|
log('WARN', `HLS audio missing on server (wav required): token="${item}" searched=[${dirList}]`);
|
|
missing.push(item);
|
|
}
|
|
}
|
|
|
|
if (HLS_TAIL_SILENCE_SEC > 0) {
|
|
for (let i = 0; i < HLS_TAIL_SILENCE_SEC; i++) files.push(silence1sWavPath);
|
|
}
|
|
|
|
const l = label ? `[${label}] ` : '';
|
|
log((missing.length === 0 ? 'DEBUG' : 'WARN'),
|
|
`${l}Sequence resolve: tokens_total=${seq.length} resolved=${files.length - (HLS_TAIL_SILENCE_SEC > 0 ? HLS_TAIL_SILENCE_SEC : 0)} missing=${missing.length} missing_list=[${missing.join(', ')}]` +
|
|
(resolved.length <= 16 ? ` resolved=[${resolved.join(' | ')}]` : ''));
|
|
|
|
return files;
|
|
}
|
|
|
|
function buildSequenceWavFile(seq, label) {
|
|
const files = resolveSequenceFiles(seq, label || 'cc-wav-build');
|
|
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, `rtmp:${streamName}`);
|
|
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 buildConcatListFile(seq, label) {
|
|
const files = resolveSequenceFiles(seq, label || 'concat-list');
|
|
if (!Array.isArray(files) || files.length === 0) return null;
|
|
const id = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
const listPath = path.join(tempBroadcastDir, `concat-${id}.txt`);
|
|
const lines = ['ffconcat version 1.0'];
|
|
for (const f of files) {
|
|
const abs = path.isAbsolute(f) ? f : path.resolve(f);
|
|
lines.push(`file '${abs.replace(/'/g, "'\\''")}'`);
|
|
}
|
|
try {
|
|
fs.writeFileSync(listPath, lines.join('\n') + '\n', 'utf8');
|
|
setTimeout(() => { try { fs.unlinkSync(listPath); } catch {} }, 30 * 60 * 1000);
|
|
return listPath;
|
|
} catch (e) {
|
|
log('ERROR', `buildConcatListFile failed: ${e.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function clearOldHlsStreamDirFiles(streamName) {
|
|
const dir = path.join(localHlsDir, streamName);
|
|
if (!fs.existsSync(dir)) { return; }
|
|
const now = Date.now();
|
|
let entries;
|
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
for (const ent of entries) {
|
|
if (!ent.isFile()) continue;
|
|
const name = ent.name;
|
|
if (!/^(b_[0-9]+_.*|index\.m3u8)$/.test(name) && !/\.ts$/.test(name)) continue;
|
|
const p = path.join(dir, name);
|
|
let mt = 0;
|
|
try { mt = fs.statSync(p).mtimeMs; } catch { continue; }
|
|
if (now - mt > 30 * 60 * 1000) {
|
|
try { fs.unlinkSync(p); } catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearLocalHlsStreamDir(streamName) {
|
|
const dir = path.join(localHlsDir, streamName);
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
try { clearOldHlsStreamDirFiles(streamName); } catch {}
|
|
const ts = Date.now();
|
|
const prefix = `b_${ts}_${Math.random().toString(16).slice(2, 6)}`;
|
|
return { dir, prefix };
|
|
}
|
|
|
|
function runFfmpegLocalHlsBuild(streamName, seq) {
|
|
const state = getOrCreateHlsStreamState(streamName);
|
|
const listPath = buildConcatListFile(seq, streamName);
|
|
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 { dir: outDir, prefix } = clearLocalHlsStreamDir(streamName);
|
|
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
|
|
const playlistPath = path.join(outDir, 'index.m3u8');
|
|
const segPattern = path.join(outDir, `${prefix}-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 = resolveStationAudioCode(data.destCode, data.destination) || 'UNK';
|
|
|
|
log('INFO', `Train ${trainId} approaching ${stationName}. Broadcasting pre-arrival... destCode=${destCode} platform=${finalPlatform}`);
|
|
|
|
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');
|
|
log('DEBUG', `[Approaching] audio tokens for ${stationName} (train=${trainId}): [${seq.join(', ')}]`);
|
|
|
|
enqueueHlsPush(stationName, seq);
|
|
|
|
const audioId = `approaching-${stationCode}-${Date.now()}`;
|
|
const built = buildSequenceWavFile(seq, `approaching:${stationName}`);
|
|
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;
|
|
}
|
|
log('DEBUG', `[Alert] audio tokens for ${stationName} (train=${trainId}): [${seq.join(', ')}]`);
|
|
|
|
enqueueHlsPush(stationName, seq);
|
|
|
|
const audioId = `alert-${stationCode}-${Date.now()}`;
|
|
const built = buildSequenceWavFile(seq, `alert:${stationName}`);
|
|
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 = resolveStationAudioCode(data.destCode, data.destination) || 'UNK';
|
|
const nextStationRaw = data.nextStation ? String(data.nextStation).trim() : '';
|
|
const nextCodeRaw = resolveStationAudioCode(data.nextCode, nextStationRaw) ||
|
|
(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'
|
|
);
|
|
}
|
|
log('DEBUG', `[Arrival] audio tokens for ${stationName} (train=${trainId} destCode=${destCode} nextCode=${finalNextCode || 'none'}): [${seq.join(', ')}]`);
|
|
|
|
enqueueHlsPush(stationName, seq);
|
|
|
|
const audioId = `arrival-${stationCode}-${Date.now()}`;
|
|
const built = buildSequenceWavFile(seq, `arrival:${stationName}`);
|
|
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 = resolveStationAudioCode(data.destCode, data.destination) || 'UNK';
|
|
const nextStationRaw = data.nextStation ? String(data.nextStation).trim() : '';
|
|
const nextCodeRaw = resolveStationAudioCode(data.nextCode, nextStationRaw) ||
|
|
(nextStationRaw ? String(data.nextCode || '').trim() || 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}`
|
|
);
|
|
}
|
|
log('DEBUG', `[Departure] audio tokens for ${stationName} (train=${trainId} destCode=${destCode} nextCode=${finalNextCode || 'none'}): [${seq.join(', ')}]`);
|
|
|
|
enqueueHlsPush(stationName, seq);
|
|
|
|
const audioId = `departure-${stationCode}-${Date.now()}`;
|
|
const built = buildSequenceWavFile(seq, `departure:${stationName}`);
|
|
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, audioCableGroups: 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();
|
|
try { ensureSilenceWav(); } catch (e) { log('WARN', `ensureSilenceWav failed at startup: ${e.message}`); }
|
|
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}`);
|
|
});
|