- 移除install和update脚本中的aukit/austream下载配置与目标,不再部署辅助脚本 - 替换station_broadcast.lua的aukit库为CC内置dfpwm模块,重写音频播放逻辑 - 添加原生扬声器API支持,支持WAV格式音频播放 - 重写云端服务器音频处理逻辑,支持直接传输WAV文件无需预转换 - 新增WAV文件时长解析、音频序列合成等辅助功能
2444 lines
86 KiB
JavaScript
2444 lines
86 KiB
JavaScript
const express = require('express');
|
|
const http = require('http');
|
|
const WebSocket = require('ws');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const bodyParser = require('body-parser');
|
|
const multer = require('multer');
|
|
const fs = require('fs');
|
|
const { execSync, spawn } = require('child_process');
|
|
const dfpwm = require('dfpwm');
|
|
const { fetch } = require('undici');
|
|
|
|
const app = express();
|
|
const server = http.createServer(app);
|
|
const wss = new WebSocket.Server({ server });
|
|
|
|
const PORT = Number(process.env.PORT || '8999');
|
|
const DEFAULT_ZH_MODEL = process.env.ZH_MODEL || '7b64d954530d490a8c40827e618e55c6';
|
|
|
|
const HLS_ENABLE = process.env.HLS_ENABLE !== '0';
|
|
const HLS_STREAM_MODE = (process.env.HLS_STREAM_MODE || 'event').toLowerCase();
|
|
const HLS_OUTPUT_MODE = (process.env.HLS_OUTPUT_MODE || 'srs').toLowerCase();
|
|
const SRS_RTMP_BASE = process.env.SRS_RTMP_BASE || 'rtmp://127.0.0.1/live';
|
|
const STABROADCAST_PUBLIC_BASE_DEFAULT = 'https://stabroadcast.fse-media.group';
|
|
const STABROADCAST_PUBLIC_BASE = process.env.STABROADCAST_PUBLIC_BASE || STABROADCAST_PUBLIC_BASE_DEFAULT;
|
|
const STABROADCAST_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 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 });
|
|
|
|
// Ensure upload directories exist
|
|
const uploadDir = path.join(__dirname, 'public', 'uploads');
|
|
const audioGeneralDir = path.join(uploadDir, 'audio', 'general');
|
|
const audioStationDir = path.join(uploadDir, 'audio', 'station');
|
|
const tempBroadcastDir = path.join(uploadDir, 'temp-broadcast');
|
|
|
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
|
if (!fs.existsSync(audioGeneralDir)) fs.mkdirSync(audioGeneralDir, { recursive: true });
|
|
if (!fs.existsSync(audioStationDir)) fs.mkdirSync(audioStationDir, { recursive: true });
|
|
if (!fs.existsSync(tempBroadcastDir)) fs.mkdirSync(tempBroadcastDir, { recursive: true });
|
|
|
|
// Multer storage configuration
|
|
const storage = multer.diskStorage({
|
|
destination: function (req, file, cb) {
|
|
if (file.fieldname === 'listFile') {
|
|
cb(null, uploadDir);
|
|
} else if (file.fieldname === 'generalAudioFiles') {
|
|
cb(null, audioGeneralDir);
|
|
} else if (file.fieldname === 'stationAudioFiles') {
|
|
cb(null, audioStationDir);
|
|
} else {
|
|
cb(null, uploadDir);
|
|
}
|
|
},
|
|
filename: function (req, file, cb) {
|
|
if (file.fieldname === 'listFile') {
|
|
cb(null, 'list.txt');
|
|
} else {
|
|
// Use original name, handle encoding if needed (multer handles basic utf8)
|
|
file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8');
|
|
cb(null, file.originalname);
|
|
}
|
|
}
|
|
});
|
|
|
|
const upload = multer({ storage: storage });
|
|
|
|
// Middleware
|
|
app.use(bodyParser.json());
|
|
app.use((req, res, next) => {
|
|
if (req.path.endsWith('.m3u8')) res.type('application/vnd.apple.mpegurl');
|
|
else if (req.path.endsWith('.ts')) res.type('video/mp2t');
|
|
next();
|
|
});
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
app.set('view engine', 'ejs');
|
|
|
|
// Data Store (In-memory)
|
|
let stations = {};
|
|
const stationRouteIndex = new Map();
|
|
let activeTrains = [];
|
|
let stationLogs = [];
|
|
let audioVersion = String(Date.now());
|
|
let versions = [];
|
|
|
|
const DATA_FILE = path.join(__dirname, 'server-data.json');
|
|
|
|
const stationAudioQueues = new Map();
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function getInterruptModeForPriority(priority) {
|
|
if (priority >= 2) return 'all';
|
|
if (priority >= 1) return 'ifLower';
|
|
return 'none';
|
|
}
|
|
|
|
function insertTaskByPriority(queue, task) {
|
|
const p = Number(task?.priority || 0);
|
|
let idx = 0;
|
|
while (idx < queue.length) {
|
|
const q = queue[idx];
|
|
const qp = Number(q?.priority || 0);
|
|
if (p > qp) break;
|
|
idx++;
|
|
}
|
|
queue.splice(idx, 0, task);
|
|
}
|
|
|
|
function getStationData(stationName) {
|
|
const cleanName = String(stationName || '').trim();
|
|
if (Object.prototype.hasOwnProperty.call(stations, cleanName)) return stations[cleanName];
|
|
|
|
// Try matching by code and platform if no direct name match
|
|
const targetCode = getStationCodeFromName(cleanName); // Already lowercase and normalized
|
|
const targetPlatform = getPlatformNumberFromName(cleanName);
|
|
|
|
if (targetCode) {
|
|
for (const k in stations) {
|
|
if (!Object.prototype.hasOwnProperty.call(stations, k)) continue;
|
|
const st = stations[k];
|
|
// Normalize st.code similarly to how targetCode was generated
|
|
const stCodeRaw = st.code || getStationCodeFromName(st.name) || '';
|
|
const stCode = stCodeRaw.replace(/_/g, '-').toLowerCase();
|
|
|
|
const stPlatform = getPlatformNumberFromName(st.name);
|
|
if (stCode === targetCode && stPlatform === targetPlatform) {
|
|
return st;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getStationWs(stationName, fallbackWs) {
|
|
const st = getStationData(stationName);
|
|
if (st && st.ws && st.ws.readyState === WebSocket.OPEN) return st.ws;
|
|
if (fallbackWs && fallbackWs.readyState === WebSocket.OPEN) return fallbackWs;
|
|
return null;
|
|
}
|
|
|
|
function getMergedStationList() {
|
|
const list = [];
|
|
for (const k in stations) {
|
|
if (!Object.prototype.hasOwnProperty.call(stations, k)) continue;
|
|
const st = stations[k];
|
|
const stCodeRaw = st.code || getStationCodeFromName(st.name) || '';
|
|
const code = stCodeRaw.replace(/_/g, '-').toLowerCase();
|
|
list.push({
|
|
name: st.name,
|
|
code: code || '',
|
|
platform: st.platform || getPlatformNumberFromName(st.name),
|
|
lastSeen: st.lastSeen,
|
|
source: 'cc',
|
|
tags: ['CC']
|
|
});
|
|
}
|
|
return list;
|
|
}
|
|
|
|
function sendAudioControl(stationName, msg, fallbackWs) {
|
|
const ws = getStationWs(stationName, fallbackWs);
|
|
if (!ws) return false;
|
|
try {
|
|
ws.send(JSON.stringify(msg));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function sendAudioBufferToStation(stationName, fallbackWs, task) {
|
|
const targetWs = getStationWs(stationName, fallbackWs);
|
|
if (!targetWs) throw new Error(`Station "${stationName}" not connected`);
|
|
|
|
const audioId = String(task.audioId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
const buf = task.buffer;
|
|
const durationSec = Number(task.durationSec || 0);
|
|
const audioFormat = String(task.audioFormat || 'dfpwm').toLowerCase();
|
|
|
|
const startMsg = {
|
|
type: 'audio_start',
|
|
stationName,
|
|
audioId,
|
|
byteLength: buf.length,
|
|
sampleRate: Number(task.sampleRate || 48000),
|
|
channels: Number(task.channels || 1),
|
|
audioFormat,
|
|
priority: Number(task.priority || 0),
|
|
interruptMode: task.interruptMode || getInterruptModeForPriority(Number(task.priority || 0)),
|
|
source: task.source || 'unknown'
|
|
};
|
|
|
|
targetWs.send(JSON.stringify(startMsg));
|
|
|
|
const token = task.cancelToken;
|
|
const SINGLE_SEND_LIMIT = 32 * 1024;
|
|
const CHUNK_SIZE = 16 * 1024;
|
|
|
|
if (buf.length <= SINGLE_SEND_LIMIT) {
|
|
if (token && token.cancelled) return { audioId, cancelled: true, durationSec };
|
|
targetWs.send(buf);
|
|
} else {
|
|
for (let offset = 0; offset < buf.length; offset += CHUNK_SIZE) {
|
|
if (token && token.cancelled) return { audioId, cancelled: true, durationSec };
|
|
const end = Math.min(offset + CHUNK_SIZE, buf.length);
|
|
targetWs.send(buf.slice(offset, end));
|
|
// For streaming, we can send slightly faster initially then steady
|
|
const isInitial = offset < CHUNK_SIZE * 4;
|
|
await delay(isInitial ? 20 : 60);
|
|
}
|
|
}
|
|
|
|
if (token && token.cancelled) return { audioId, cancelled: true, durationSec };
|
|
|
|
targetWs.send(JSON.stringify({
|
|
type: 'audio_end',
|
|
stationName,
|
|
audioId,
|
|
durationSec
|
|
}));
|
|
|
|
return { audioId, cancelled: false, durationSec };
|
|
}
|
|
|
|
const stationMusicStates = new Map();
|
|
|
|
function normalizeBaseName(name) {
|
|
const s = String(name || '').trim();
|
|
if (!s) return null;
|
|
const base = s.replace(/\.(wav|dfpwm)$/i, '').trim();
|
|
return base || null;
|
|
}
|
|
|
|
function listMusicTracks() {
|
|
const out = new Set();
|
|
if (!fs.existsSync(audioGeneralDir)) return [];
|
|
|
|
for (const file of fs.readdirSync(audioGeneralDir)) {
|
|
if (!file || file.startsWith('.')) continue;
|
|
const full = path.join(audioGeneralDir, file);
|
|
if (!fs.existsSync(full) || !fs.statSync(full).isFile()) continue;
|
|
if (!file.toLowerCase().endsWith('.wav') && !file.toLowerCase().endsWith('.dfpwm')) continue;
|
|
const base = normalizeBaseName(file);
|
|
if (base) out.add(base);
|
|
}
|
|
|
|
return Array.from(out).sort((a, b) => a.localeCompare(b));
|
|
}
|
|
|
|
function getWavDurationSec(buffer) {
|
|
if (!Buffer.isBuffer(buffer) || buffer.length < 44) return 0;
|
|
if (buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') return 0;
|
|
|
|
let offset = 12;
|
|
let sampleRate = 0;
|
|
let channels = 0;
|
|
let bitsPerSample = 0;
|
|
let dataSize = 0;
|
|
|
|
while (offset + 8 <= buffer.length) {
|
|
const chunkId = buffer.toString('ascii', offset, offset + 4);
|
|
const chunkSize = buffer.readUInt32LE(offset + 4);
|
|
const dataOffset = offset + 8;
|
|
if (dataOffset + chunkSize > buffer.length) break;
|
|
|
|
if (chunkId === 'fmt ' && chunkSize >= 16) {
|
|
channels = buffer.readUInt16LE(dataOffset + 2);
|
|
sampleRate = buffer.readUInt32LE(dataOffset + 4);
|
|
bitsPerSample = buffer.readUInt16LE(dataOffset + 14);
|
|
} else if (chunkId === 'data') {
|
|
dataSize = chunkSize;
|
|
break;
|
|
}
|
|
|
|
offset = dataOffset + chunkSize + (chunkSize % 2);
|
|
}
|
|
|
|
if (!sampleRate || !channels || !bitsPerSample || !dataSize) return 0;
|
|
return dataSize / (sampleRate * channels * (bitsPerSample / 8));
|
|
}
|
|
|
|
function getPlaybackAudioByBaseName(baseName) {
|
|
const base = normalizeBaseName(baseName);
|
|
if (!base) throw new Error('Invalid track name');
|
|
|
|
const wavCandidates = [
|
|
path.join(audioGeneralDir, `${base}.wav`),
|
|
path.join(audioStationDir, `${base}.wav`)
|
|
];
|
|
for (const p of wavCandidates) {
|
|
if (fs.existsSync(p)) {
|
|
const buffer = fs.readFileSync(p);
|
|
return {
|
|
buffer,
|
|
audioFormat: 'wav',
|
|
durationSec: getWavDurationSec(buffer) || 0
|
|
};
|
|
}
|
|
}
|
|
|
|
const dfCandidates = [
|
|
path.join(audioGeneralDir, `${base}.dfpwm`),
|
|
path.join(audioStationDir, `${base}.dfpwm`)
|
|
];
|
|
for (const p of dfCandidates) {
|
|
if (fs.existsSync(p)) {
|
|
const buffer = fs.readFileSync(p);
|
|
return {
|
|
buffer,
|
|
audioFormat: 'dfpwm',
|
|
durationSec: buffer.length / 6000
|
|
};
|
|
}
|
|
}
|
|
|
|
throw new Error(`Audio not found: ${base}`);
|
|
}
|
|
|
|
function 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') {
|
|
if (q.current.cancelToken) q.current.cancelToken.cancelled = true;
|
|
if (q.current.endTimer) {
|
|
try { clearTimeout(q.current.endTimer); } catch {}
|
|
q.current.endTimer = null;
|
|
}
|
|
}
|
|
sendAudioControl(cleanStation, { type: 'audio_stop', scope: 'music', mode: 'current', reason: 'music_stop' }, fallbackWs);
|
|
}
|
|
|
|
function getStationAudioQueue(stationName) {
|
|
const st = getStationData(stationName);
|
|
const key = st ? st.name : String(stationName || '').trim();
|
|
|
|
if (!stationAudioQueues.has(key)) {
|
|
stationAudioQueues.set(key, {
|
|
processing: false,
|
|
current: null,
|
|
queue: []
|
|
});
|
|
}
|
|
return stationAudioQueues.get(key);
|
|
}
|
|
|
|
function enqueueStationAudioTask(stationName, fallbackWs, task) {
|
|
const cleanStation = String(stationName || '').trim();
|
|
const q = getStationAudioQueue(cleanStation);
|
|
const newP = Number(task.priority || 0);
|
|
const cur = q.current;
|
|
|
|
if (task.interruptMode === 'all' || newP >= 2) {
|
|
if (cur && cur.cancelToken) cur.cancelToken.cancelled = true;
|
|
if (cur && cur.endTimer) {
|
|
try { clearTimeout(cur.endTimer); } catch {}
|
|
cur.endTimer = null;
|
|
}
|
|
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) {
|
|
if (cur.cancelToken) cur.cancelToken.cancelled = true;
|
|
if (cur.endTimer) {
|
|
try { clearTimeout(cur.endTimer); } catch {}
|
|
cur.endTimer = null;
|
|
}
|
|
sendAudioControl(cleanStation, { type: 'audio_stop', mode: 'current', flushBelow: newP, reason: 'preempt' }, fallbackWs);
|
|
if (cur.requeueOnPreempt) {
|
|
const replay = { ...cur, cancelToken: null };
|
|
replay.audioId = `${String(replay.audioId || '')}-replay-${Date.now()}`;
|
|
insertTaskByPriority(q.queue, replay);
|
|
}
|
|
}
|
|
}
|
|
|
|
insertTaskByPriority(q.queue, task);
|
|
processStationAudioQueue(cleanStation, fallbackWs).catch(() => {});
|
|
}
|
|
|
|
async function processStationAudioQueue(stationName, fallbackWs) {
|
|
const cleanStation = String(stationName || '').trim();
|
|
const q = getStationAudioQueue(cleanStation);
|
|
if (q.processing) return;
|
|
q.processing = true;
|
|
try {
|
|
while (q.queue.length > 0) {
|
|
const task = q.queue.shift();
|
|
task.cancelToken = { cancelled: false };
|
|
q.current = task;
|
|
|
|
try {
|
|
if (task.consoleStreamStart) broadcastToConsole(task.consoleStreamStart);
|
|
const result = await sendAudioBufferToStation(cleanStation, fallbackWs, task);
|
|
if (task.consoleStreamEnd) {
|
|
if (result.cancelled) {
|
|
broadcastToConsole(task.consoleStreamEnd);
|
|
} else {
|
|
task.endTimer = setTimeout(() => {
|
|
try { broadcastToConsole(task.consoleStreamEnd); } catch {}
|
|
}, Math.max(0, Number(result.durationSec || 0) * 1000));
|
|
}
|
|
}
|
|
if (task.onDone) task.onDone({ ...result, stationName: cleanStation });
|
|
} catch (e) {
|
|
if (task.consoleStreamEnd) broadcastToConsole(task.consoleStreamEnd);
|
|
if (task.onDone) task.onDone({ cancelled: true, error: e?.message || String(e), stationName: cleanStation });
|
|
} finally {
|
|
q.current = null;
|
|
}
|
|
}
|
|
} finally {
|
|
q.processing = false;
|
|
}
|
|
}
|
|
|
|
function loadData() {
|
|
if (fs.existsSync(DATA_FILE)) {
|
|
try {
|
|
const data = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
|
|
if (data.stations) stations = data.stations;
|
|
if (data.activeTrains) activeTrains = data.activeTrains;
|
|
if (data.audioVersion) audioVersion = String(data.audioVersion);
|
|
if (Array.isArray(data.versions)) versions = data.versions;
|
|
refreshStationIndex();
|
|
log('INFO', 'Data loaded from disk');
|
|
} catch (e) {
|
|
log('ERROR', `Failed to load data: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function saveData() {
|
|
try {
|
|
// Create a copy of stations without ws objects
|
|
const stationsToSave = {};
|
|
for (const key in stations) {
|
|
const { ws, ...rest } = stations[key];
|
|
stationsToSave[key] = rest;
|
|
}
|
|
|
|
const data = {
|
|
stations: stationsToSave,
|
|
activeTrains,
|
|
audioVersion,
|
|
versions
|
|
};
|
|
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
|
|
} catch (e) {
|
|
log('ERROR', `Failed to save data: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
function normalizeVersionString(input) {
|
|
if (input === undefined || input === null) return '';
|
|
if (typeof input !== 'string') input = String(input);
|
|
input = input.trim();
|
|
if (input.length > 64) input = input.slice(0, 64);
|
|
return input;
|
|
}
|
|
|
|
function getManagedFiles() {
|
|
const files = [];
|
|
|
|
const configPath = path.join(uploadDir, 'list.txt');
|
|
if (fs.existsSync(configPath)) {
|
|
const stats = fs.statSync(configPath);
|
|
files.push({
|
|
name: 'list.txt',
|
|
path: 'uploads/list.txt',
|
|
type: 'config',
|
|
size: stats.size,
|
|
mtime: stats.mtimeMs
|
|
});
|
|
}
|
|
|
|
if (fs.existsSync(audioGeneralDir)) {
|
|
fs.readdirSync(audioGeneralDir).forEach(file => {
|
|
if (file.endsWith('.dfpwm')) {
|
|
const wavName = file.replace(/\.dfpwm$/, '.wav');
|
|
if (fs.existsSync(path.join(audioGeneralDir, wavName))) return;
|
|
}
|
|
const filePath = path.join(audioGeneralDir, file);
|
|
const stats = fs.statSync(filePath);
|
|
files.push({
|
|
name: file,
|
|
path: `uploads/audio/general/${file}`,
|
|
type: 'general',
|
|
size: stats.size,
|
|
mtime: stats.mtimeMs
|
|
});
|
|
});
|
|
}
|
|
|
|
if (fs.existsSync(audioStationDir)) {
|
|
fs.readdirSync(audioStationDir).forEach(file => {
|
|
if (file.endsWith('.dfpwm')) {
|
|
const wavName = file.replace(/\.dfpwm$/, '.wav');
|
|
if (fs.existsSync(path.join(audioStationDir, wavName))) return;
|
|
}
|
|
const filePath = path.join(audioStationDir, file);
|
|
const stats = fs.statSync(filePath);
|
|
files.push({
|
|
name: file,
|
|
path: `uploads/audio/station/${file}`,
|
|
type: 'station',
|
|
size: stats.size,
|
|
mtime: stats.mtimeMs
|
|
});
|
|
});
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
function snapshotFiles() {
|
|
const snapshot = {};
|
|
const files = getManagedFiles();
|
|
for (const f of files) {
|
|
snapshot[f.path] = { mtime: f.mtime, size: f.size, type: f.type, name: f.name };
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
function diffSnapshots(prevSnap, nextSnap) {
|
|
const prev = prevSnap || {};
|
|
const next = nextSnap || {};
|
|
const added = [];
|
|
const modified = [];
|
|
const deleted = [];
|
|
|
|
for (const p of Object.keys(next)) {
|
|
if (!prev[p]) added.push(p);
|
|
else if (prev[p].mtime !== next[p].mtime || prev[p].size !== next[p].size) modified.push(p);
|
|
}
|
|
for (const p of Object.keys(prev)) {
|
|
if (!next[p]) deleted.push(p);
|
|
}
|
|
|
|
added.sort();
|
|
modified.sort();
|
|
deleted.sort();
|
|
|
|
return { added, modified, deleted };
|
|
}
|
|
|
|
function log(type, message) {
|
|
const timestamp = new Date().toISOString();
|
|
const logEntry = `[${timestamp}] [${type}] ${message}`;
|
|
console.log(logEntry);
|
|
stationLogs.unshift(logEntry);
|
|
if (stationLogs.length > 100) stationLogs.pop();
|
|
|
|
// Broadcast logs to web console
|
|
broadcastToConsole({ type: 'log', data: logEntry });
|
|
}
|
|
|
|
const trainEventCooldown = new Map(); // trainId|stationName|type -> timestamp
|
|
function normalizeName(value) {
|
|
return String(value || '').trim();
|
|
}
|
|
|
|
function getStationCodeFromName(name) {
|
|
if (!name) return null;
|
|
|
|
// 1. Try to find ID in parentheses or brackets (e.g. "Station (01-01/01-02)")
|
|
const parenMatch = name.match(/[\(\[]([a-zA-Z0-9\-\/]+)[\)\]]/);
|
|
if (parenMatch) return parenMatch[1].split('/')[0].replace('_', '-').trim().toLowerCase();
|
|
|
|
// 2. Try extraction from anywhere in string (e.g. "01-01" or "01_01")
|
|
const m = name.match(/([a-zA-Z0-9]+[-_][a-zA-Z0-9]+)/);
|
|
if (m) return m[1].replace('_', '-').toLowerCase();
|
|
|
|
// 3. Fallback to first part of name if it looks like a code or a name
|
|
const parts = name.split(/[\s?]+/);
|
|
const firstPart = parts[0];
|
|
|
|
// Ignore common generic parts
|
|
if (firstPart === 'Station' || firstPart === 'Platform' || firstPart === 'Track') {
|
|
if (parts[1]) return parts[1].toLowerCase();
|
|
}
|
|
|
|
if (firstPart && firstPart.length > 0) {
|
|
const code = firstPart.replace('_', '-').toLowerCase();
|
|
// log('DEBUG', `Extracted code "${code}" from "${name}"`);
|
|
return code;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getPlatformNumberFromName(name) {
|
|
if (!name) return "1";
|
|
// 1. Try "Platform X" or "Track X" (case insensitive)
|
|
const m1 = name.match(/(?:platform|track)\s*([0-9]+[a-zA-Z]?)/i);
|
|
if (m1) return m1[1];
|
|
|
|
// 2. Fallback to digits at the end
|
|
const m2 = name.match(/([0-9]+[a-zA-Z]?)$/);
|
|
return m2 ? m2[1] : "1";
|
|
}
|
|
|
|
function normalizeStationCode(code) {
|
|
const value = String(code || '').trim().replace(/_/g, '-').toLowerCase();
|
|
return value || null;
|
|
}
|
|
|
|
function normalizeStationPlatform(platform) {
|
|
const value = String(platform || '').trim().toLowerCase();
|
|
return value || '1';
|
|
}
|
|
|
|
function getStationRouteKeys(name, code, platform) {
|
|
const keys = [];
|
|
const normalizedCode = normalizeStationCode(code || getStationCodeFromName(name));
|
|
const normalizedPlatform = normalizeStationPlatform(platform || getPlatformNumberFromName(name));
|
|
const baseName = getBaseName(name);
|
|
|
|
if (normalizedCode) {
|
|
keys.push(`code:${normalizedCode}|platform:${normalizedPlatform}`);
|
|
keys.push(`code:${normalizedCode}`);
|
|
}
|
|
if (baseName) {
|
|
keys.push(`base:${baseName}|platform:${normalizedPlatform}`);
|
|
keys.push(`base:${baseName}`);
|
|
}
|
|
return [...new Set(keys)];
|
|
}
|
|
|
|
function unindexStation(station) {
|
|
if (!station) return;
|
|
const keys = Array.isArray(station.routeKeys) ? station.routeKeys : [];
|
|
for (const key of keys) {
|
|
const current = stationRouteIndex.get(key);
|
|
if (current && current.name === station.name) {
|
|
stationRouteIndex.delete(key);
|
|
}
|
|
}
|
|
station.routeKeys = [];
|
|
}
|
|
|
|
function indexStation(station) {
|
|
if (!station || !station.name) return;
|
|
unindexStation(station);
|
|
station.platform = normalizeStationPlatform(station.platform || getPlatformNumberFromName(station.name));
|
|
station.code = normalizeStationCode(station.code || getStationCodeFromName(station.name)) || station.code || '';
|
|
station.routeKeys = getStationRouteKeys(station.name, station.code, station.platform);
|
|
for (const key of station.routeKeys) {
|
|
stationRouteIndex.set(key, station);
|
|
}
|
|
}
|
|
|
|
function refreshStationIndex() {
|
|
stationRouteIndex.clear();
|
|
for (const station of Object.values(stations)) {
|
|
indexStation(station);
|
|
}
|
|
}
|
|
|
|
function getBaseTrainId(trainId) {
|
|
if (!trainId) return "unknown";
|
|
|
|
// Ignore split off trains which are temporary and cause false announcements
|
|
if (trainId.includes("Split off from")) return null;
|
|
|
|
// Remove car-specific info like "01 3501F" from "CK 1 01 3501F"
|
|
// Also remove brackets like "[Local] Train 1" -> "Train 1"
|
|
let clean = trainId.replace(/^\[.*?\]\s*/, '');
|
|
|
|
// Look for patterns like "CK 1 01" and keep only "CK 1"
|
|
const parts = clean.split(/\s+/);
|
|
if (parts.length >= 3) {
|
|
// If it looks like "NAME NUM CAR ...", keep "NAME NUM"
|
|
if (parts[0].length <= 8 && !isNaN(parts[1])) {
|
|
return `${parts[0]} ${parts[1]}`;
|
|
}
|
|
}
|
|
|
|
// Handle dash patterns like "123-01" -> "123"
|
|
const dashMatch = parts[0].match(/^([\w\d]+)\-[\w\d]+$/);
|
|
if (dashMatch) return dashMatch[1];
|
|
|
|
return parts[0] + (parts[1] ? ' ' + parts[1] : '');
|
|
}
|
|
|
|
// Radio Listeners
|
|
const radioListeners = {};
|
|
const hlsStreams = {};
|
|
|
|
function sanitizeStreamName(name) {
|
|
return String(name || '')
|
|
.trim()
|
|
.replace(/\s+/g, '_')
|
|
.replace(/[^a-zA-Z0-9_\-]/g, '_')
|
|
.replace(/_+/g, '_')
|
|
.replace(/^_+|_+$/g, '')
|
|
.slice(0, 80) || 'UNK';
|
|
}
|
|
|
|
function getPrimaryStationCode(code) {
|
|
if (!code) return null;
|
|
const first = String(code).split(/[,\s|/]+/).map(s => s.trim()).filter(Boolean)[0];
|
|
return first || null;
|
|
}
|
|
|
|
function getStationStreamName(stationName) {
|
|
const st = getStationData(stationName);
|
|
const primaryCode = getPrimaryStationCode(st?.code);
|
|
return sanitizeStreamName(primaryCode || stationName);
|
|
}
|
|
|
|
function getStationHlsUrl(streamName) {
|
|
const base = String(STABROADCAST_PUBLIC_BASE || '').replace(/\/+$/g, '');
|
|
return `${base}/${encodeURIComponent(streamName)}${STABROADCAST_HLS_SUFFIX}`;
|
|
}
|
|
|
|
function guessSrsHttpBase(req) {
|
|
const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim();
|
|
const hostRaw = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim();
|
|
if (!hostRaw) return null;
|
|
|
|
const m = hostRaw.match(/^(.+?)(?::(\d+))?$/);
|
|
const host = m?.[1] || hostRaw;
|
|
const port = m?.[2];
|
|
|
|
const withPort = port ? `${host}:8080` : `${host}:8080`;
|
|
return `${proto}://${withPort}/live`;
|
|
}
|
|
|
|
function getStationHlsUrlForRequest(streamName, req) {
|
|
const base = String(process.env.STABROADCAST_PUBLIC_BASE || guessSrsHttpBase(req) || STABROADCAST_PUBLIC_BASE_DEFAULT).replace(/\/+$/g, '');
|
|
return `${base}/${encodeURIComponent(streamName)}${STABROADCAST_HLS_SUFFIX}`;
|
|
}
|
|
|
|
function getLocalHlsUrlForRequest(streamName, req) {
|
|
const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim();
|
|
const hostRaw = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim();
|
|
const base = hostRaw ? `${proto}://${hostRaw}` : '';
|
|
return `${base}/hls/${encodeURIComponent(streamName)}/index.m3u8`;
|
|
}
|
|
|
|
function ensureSilenceWav() {
|
|
if (fs.existsSync(silence1sWavPath)) return;
|
|
execSync(`ffmpeg -y -v quiet -f lavfi -i anullsrc=r=48000:cl=mono -t 1 -c:a pcm_s16le "${silence1sWavPath}"`);
|
|
}
|
|
|
|
function getOrCreateHlsStreamState(streamName) {
|
|
if (!hlsStreams[streamName]) {
|
|
hlsStreams[streamName] = {
|
|
streamName,
|
|
stationName: null,
|
|
stationCode: null,
|
|
hlsUrl: getStationHlsUrl(streamName),
|
|
status: 'idle',
|
|
lastStartAt: null,
|
|
lastEndAt: null,
|
|
lastSeq: null,
|
|
lastError: null,
|
|
lastExitCode: null,
|
|
queue: Promise.resolve(),
|
|
currentProcess: null
|
|
};
|
|
}
|
|
return hlsStreams[streamName];
|
|
}
|
|
|
|
function buildConcatListFile(seq) {
|
|
ensureSilenceWav();
|
|
const files = [];
|
|
|
|
for (const item of seq) {
|
|
if (item === '__DELAY_1__') {
|
|
files.push(silence1sWavPath);
|
|
continue;
|
|
}
|
|
|
|
const candidates = [
|
|
path.join(audioStationDir, `${item}.wav`),
|
|
path.join(audioGeneralDir, `${item}.wav`)
|
|
];
|
|
|
|
const found = candidates.find(p => fs.existsSync(p));
|
|
if (found) {
|
|
files.push(found);
|
|
} else {
|
|
log('WARN', `HLS audio missing on server (wav required): ${item}`);
|
|
}
|
|
}
|
|
|
|
if (HLS_TAIL_SILENCE_SEC > 0) {
|
|
for (let i = 0; i < HLS_TAIL_SILENCE_SEC; i++) files.push(silence1sWavPath);
|
|
}
|
|
|
|
if (files.length === 0) return null;
|
|
|
|
const listPath = path.join(tmpDir, `concat-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`);
|
|
const content = files
|
|
.map(p => `file '${p.replace(/\\/g, '/').replace(/'/g, "\\'")}'`)
|
|
.join('\n');
|
|
fs.writeFileSync(listPath, content, 'utf8');
|
|
return listPath;
|
|
}
|
|
|
|
function buildSequenceWavBuffer(seq) {
|
|
const listPath = buildConcatListFile(seq);
|
|
if (!listPath) return null;
|
|
|
|
const outWavPath = path.join(tmpDir, `sequence-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`);
|
|
try {
|
|
execSync(`ffmpeg -hide_banner -loglevel error -y -f concat -safe 0 -i "${listPath}" -ac 1 -ar 48000 -c:a pcm_s16le "${outWavPath}"`);
|
|
const buffer = fs.readFileSync(outWavPath);
|
|
return {
|
|
buffer,
|
|
audioFormat: 'wav',
|
|
durationSec: getWavDurationSec(buffer) || 0
|
|
};
|
|
} finally {
|
|
try { fs.unlinkSync(listPath); } catch {}
|
|
try { fs.unlinkSync(outWavPath); } catch {}
|
|
}
|
|
}
|
|
|
|
function runFfmpegRtmpPush(streamName, seq) {
|
|
const state = getOrCreateHlsStreamState(streamName);
|
|
const listPath = buildConcatListFile(seq);
|
|
if (!listPath) {
|
|
state.status = 'idle';
|
|
state.lastError = 'No playable wav files in sequence';
|
|
return Promise.resolve();
|
|
}
|
|
|
|
const rtmpUrl = `${SRS_RTMP_BASE.replace(/\/+$/g, '')}/${encodeURIComponent(streamName)}`;
|
|
|
|
state.lastSeq = seq;
|
|
state.lastError = null;
|
|
state.lastExitCode = null;
|
|
|
|
if (HLS_PREBUILD) {
|
|
const tmpAacPath = path.join(tmpDir, `prebuild-${Date.now()}-${Math.random().toString(16).slice(2)}.aac`);
|
|
const buildArgs = [
|
|
'-hide_banner',
|
|
'-loglevel',
|
|
'error',
|
|
'-y',
|
|
'-f',
|
|
'concat',
|
|
'-safe',
|
|
'0',
|
|
'-i',
|
|
listPath,
|
|
'-ac',
|
|
'1',
|
|
'-ar',
|
|
'48000',
|
|
'-c:a',
|
|
'aac',
|
|
'-b:a',
|
|
'128k',
|
|
'-f',
|
|
'adts',
|
|
tmpAacPath
|
|
];
|
|
|
|
state.status = 'building';
|
|
state.lastStartAt = Date.now();
|
|
|
|
const buildProc = spawn('ffmpeg', buildArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
let buildStderr = '';
|
|
buildProc.stderr.on('data', (buf) => {
|
|
buildStderr += buf.toString('utf8');
|
|
if (buildStderr.length > 8000) buildStderr = buildStderr.slice(-8000);
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
buildProc.on('close', (buildCode) => {
|
|
if (buildCode !== 0) {
|
|
state.lastExitCode = buildCode;
|
|
state.status = 'idle';
|
|
state.lastEndAt = Date.now();
|
|
state.lastError = (buildStderr || `ffmpeg exited with code ${buildCode}`).trim();
|
|
log('ERROR', `HLS build failed for ${streamName}: ${state.lastError}`);
|
|
try { fs.unlinkSync(listPath); } catch {}
|
|
try { fs.unlinkSync(tmpAacPath); } catch {}
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const pushArgs = [
|
|
'-hide_banner',
|
|
'-loglevel',
|
|
'error',
|
|
'-re',
|
|
'-i',
|
|
tmpAacPath,
|
|
'-c:a',
|
|
'copy',
|
|
'-f',
|
|
'flv',
|
|
rtmpUrl
|
|
];
|
|
|
|
state.status = 'pushing';
|
|
const proc = spawn('ffmpeg', pushArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
state.currentProcess = proc;
|
|
|
|
let stderr = '';
|
|
proc.stderr.on('data', (buf) => {
|
|
stderr += buf.toString('utf8');
|
|
if (stderr.length > 8000) stderr = stderr.slice(-8000);
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
state.lastExitCode = code;
|
|
state.status = 'idle';
|
|
state.lastEndAt = Date.now();
|
|
state.currentProcess = null;
|
|
if (code !== 0) {
|
|
state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim();
|
|
log('ERROR', `HLS push failed for ${streamName}: ${state.lastError}`);
|
|
}
|
|
try { fs.unlinkSync(listPath); } catch {}
|
|
try { fs.unlinkSync(tmpAacPath); } catch {}
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
const args = [
|
|
'-hide_banner',
|
|
'-loglevel',
|
|
'error',
|
|
'-re',
|
|
'-f',
|
|
'concat',
|
|
'-safe',
|
|
'0',
|
|
'-i',
|
|
listPath,
|
|
'-ac',
|
|
'1',
|
|
'-ar',
|
|
'48000',
|
|
'-c:a',
|
|
'aac',
|
|
'-b:a',
|
|
'128k',
|
|
'-f',
|
|
'flv',
|
|
rtmpUrl
|
|
];
|
|
|
|
state.status = 'pushing';
|
|
state.lastStartAt = Date.now();
|
|
|
|
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
state.currentProcess = proc;
|
|
|
|
let stderr = '';
|
|
proc.stderr.on('data', (buf) => {
|
|
stderr += buf.toString('utf8');
|
|
if (stderr.length > 8000) stderr = stderr.slice(-8000);
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
proc.on('close', (code) => {
|
|
state.lastExitCode = code;
|
|
state.status = 'idle';
|
|
state.lastEndAt = Date.now();
|
|
state.currentProcess = null;
|
|
if (code !== 0) {
|
|
state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim();
|
|
log('ERROR', `HLS push failed for ${streamName}: ${state.lastError}`);
|
|
}
|
|
try { fs.unlinkSync(listPath); } catch {}
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function clearLocalHlsStreamDir(streamName) {
|
|
const dir = path.join(localHlsDir, streamName);
|
|
if (!fs.existsSync(dir)) return dir;
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
for (const ent of entries) {
|
|
const p = path.join(dir, ent.name);
|
|
if (ent.isDirectory()) {
|
|
fs.rmSync(p, { recursive: true, force: true });
|
|
} else {
|
|
try { fs.unlinkSync(p); } catch {}
|
|
}
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
function runFfmpegLocalHlsBuild(streamName, seq) {
|
|
const state = getOrCreateHlsStreamState(streamName);
|
|
const listPath = buildConcatListFile(seq);
|
|
if (!listPath) {
|
|
state.status = 'idle';
|
|
state.lastError = 'No playable wav files in sequence';
|
|
return Promise.resolve();
|
|
}
|
|
|
|
state.lastSeq = seq;
|
|
state.lastError = null;
|
|
state.lastExitCode = null;
|
|
state.status = 'building';
|
|
state.lastStartAt = Date.now();
|
|
|
|
const outDir = clearLocalHlsStreamDir(streamName);
|
|
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
|
|
const playlistPath = path.join(outDir, 'index.m3u8');
|
|
const segPattern = path.join(outDir, 'seg-%05d.ts');
|
|
const args = [
|
|
'-hide_banner',
|
|
'-loglevel',
|
|
'error',
|
|
'-y',
|
|
'-f',
|
|
'concat',
|
|
'-safe',
|
|
'0',
|
|
'-i',
|
|
listPath,
|
|
'-ac',
|
|
'1',
|
|
'-ar',
|
|
'48000',
|
|
'-c:a',
|
|
'aac',
|
|
'-b:a',
|
|
'128k',
|
|
'-f',
|
|
'hls',
|
|
'-hls_time',
|
|
'2',
|
|
'-hls_list_size',
|
|
'0',
|
|
'-hls_playlist_type',
|
|
'vod',
|
|
'-hls_flags',
|
|
'independent_segments',
|
|
'-hls_segment_filename',
|
|
segPattern,
|
|
playlistPath
|
|
];
|
|
|
|
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
state.currentProcess = proc;
|
|
|
|
let stderr = '';
|
|
proc.stderr.on('data', (buf) => {
|
|
stderr += buf.toString('utf8');
|
|
if (stderr.length > 8000) stderr = stderr.slice(-8000);
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
proc.on('close', (code) => {
|
|
state.lastExitCode = code;
|
|
state.status = 'idle';
|
|
state.lastEndAt = Date.now();
|
|
state.currentProcess = null;
|
|
if (code !== 0) {
|
|
state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim();
|
|
log('ERROR', `Local HLS build failed for ${streamName}: ${state.lastError}`);
|
|
}
|
|
try { fs.unlinkSync(listPath); } catch {}
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function enqueueHlsPush(stationName, seq) {
|
|
if (!HLS_ENABLE) return;
|
|
if (!Array.isArray(seq) || seq.length === 0) return;
|
|
|
|
const streamName = getStationStreamName(stationName);
|
|
const st = getStationData(stationName);
|
|
const state = getOrCreateHlsStreamState(streamName);
|
|
state.stationName = stationName;
|
|
state.stationCode = getPrimaryStationCode(st?.code);
|
|
state.hlsUrl = state.hlsUrl || getStationHlsUrl(streamName);
|
|
|
|
if (HLS_STREAM_MODE === 'replace' && state.currentProcess) {
|
|
try { state.currentProcess.kill('SIGKILL'); } catch {}
|
|
}
|
|
|
|
state.queue = state.queue
|
|
.then(() => (HLS_OUTPUT_MODE === 'local' ? runFfmpegLocalHlsBuild(streamName, seq) : runFfmpegRtmpPush(streamName, seq)))
|
|
.catch((e) => {
|
|
state.status = 'idle';
|
|
state.lastError = e?.message || String(e);
|
|
log('ERROR', `HLS push error for ${streamName}: ${state.lastError}`);
|
|
});
|
|
}
|
|
|
|
// WebSocket handling
|
|
wss.on('connection', (ws, req) => {
|
|
const ip = req.socket.remoteAddress;
|
|
|
|
// Check if it's a radio connection
|
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
if (url.pathname === '/api/radio') {
|
|
const station = url.searchParams.get('station');
|
|
if (station) {
|
|
const cleanStation = station.trim();
|
|
if (!radioListeners[cleanStation]) radioListeners[cleanStation] = [];
|
|
|
|
// To prevent zombie connections, close existing connections from the same IP
|
|
// Since CC might reconnect quickly and leave old ones hanging
|
|
radioListeners[cleanStation] = radioListeners[cleanStation].filter(existingWs => {
|
|
if (existingWs.readyState !== WebSocket.OPEN) return false;
|
|
// If it's the same IP connecting again, close the old one to save CC resources
|
|
if (existingWs._socket && req.socket && existingWs._socket.remoteAddress === req.socket.remoteAddress) {
|
|
existingWs.close();
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
radioListeners[cleanStation].push(ws);
|
|
log('INFO', `Radio listener connected for station: ${cleanStation}`);
|
|
|
|
// Send an empty initial frame to let the client know it's connected and ready
|
|
// DFPWM silence is 0xAA. 24000 bytes = 4 seconds of silence to pre-fill buffer.
|
|
ws.send(Buffer.alloc(24000, 0xAA));
|
|
|
|
ws.on('close', () => {
|
|
radioListeners[cleanStation] = radioListeners[cleanStation].filter(l => l !== ws);
|
|
log('INFO', `Radio listener disconnected for station: ${cleanStation}`);
|
|
});
|
|
|
|
// Ping to keep alive
|
|
const pingInterval = setInterval(() => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.ping();
|
|
} else {
|
|
clearInterval(pingInterval);
|
|
}
|
|
}, 30000);
|
|
|
|
ws.on('close', () => clearInterval(pingInterval));
|
|
return;
|
|
}
|
|
}
|
|
|
|
ws.on('message', (message) => {
|
|
try {
|
|
const data = JSON.parse(message);
|
|
handleMessage(ws, data);
|
|
} catch (e) {
|
|
log('ERROR', `Failed to parse message: ${e.message}`);
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
// Handle disconnection if necessary
|
|
});
|
|
});
|
|
|
|
function handleMessage(ws, data) {
|
|
// Check if it's a console connection
|
|
if (data.type === 'console_connect') {
|
|
ws.isConsole = true;
|
|
// Send initial state
|
|
ws.send(JSON.stringify({
|
|
type: 'init_state',
|
|
stations,
|
|
activeTrains,
|
|
logs: stationLogs
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// Handle Station Messages
|
|
switch (data.type) {
|
|
case 'station_register':
|
|
handleStationRegister(ws, data);
|
|
break;
|
|
case 'train_arrival':
|
|
handleTrainArrival(data);
|
|
break;
|
|
case 'train_departure':
|
|
handleTrainDeparture(data);
|
|
break;
|
|
case 'request_station_time':
|
|
handleStationTimeRequest(ws, data);
|
|
break;
|
|
case 'play_sequence':
|
|
handlePlaySequence(ws, data);
|
|
break;
|
|
case 'request_list_txt':
|
|
// TODO: Send list.txt content
|
|
break;
|
|
case 'heartbeat':
|
|
handleHeartbeat(ws, data);
|
|
break;
|
|
default:
|
|
log('WARNING', `Unknown message type: ${data.type}`);
|
|
}
|
|
}
|
|
|
|
function getDestinationFromSchedule(schedule) {
|
|
if (!schedule || !schedule.entries) return "Unknown";
|
|
const entries = schedule.entries;
|
|
let dest = "Unknown";
|
|
|
|
// Convert entries to array if it's an object with numeric keys
|
|
const entryList = Array.isArray(entries) ? entries : Object.values(entries);
|
|
|
|
// 1. Look for explicit destination instructions
|
|
for (const entry of entryList) {
|
|
if (entry.instruction?.id === 'create:destination') {
|
|
dest = entry.instruction.data?.text || entry.instruction.text || dest;
|
|
}
|
|
}
|
|
|
|
// 2. If still Unknown, find the very last station instruction
|
|
if (dest === "Unknown") {
|
|
for (let i = entryList.length - 1; i >= 0; i--) {
|
|
if (entryList[i].instruction?.id === 'create:station') {
|
|
dest = entryList[i].instruction.data?.text || entryList[i].instruction.text || dest;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return dest;
|
|
}
|
|
|
|
function getCorrectStationName(name) {
|
|
const cleanName = String(name || '').trim();
|
|
const station = findStationByFlexibleName(cleanName);
|
|
if (station && station.name) {
|
|
return station.name;
|
|
}
|
|
return cleanName;
|
|
}
|
|
|
|
function handleHeartbeat(ws, data) {
|
|
if (data.stationName) {
|
|
const cleanName = data.stationName.trim();
|
|
if (!stations[cleanName]) {
|
|
// Auto-register if missing (e.g. server restarted)
|
|
stations[cleanName] = {
|
|
name: cleanName,
|
|
code: data.stationCode || '',
|
|
platform: data.stationPlatform || getPlatformNumberFromName(cleanName),
|
|
lastSeen: Date.now(),
|
|
ws: ws
|
|
};
|
|
indexStation(stations[cleanName]);
|
|
log('INFO', `Station auto-registered via heartbeat: "${cleanName}"`);
|
|
broadcastUpdate();
|
|
} else {
|
|
stations[cleanName].lastSeen = Date.now();
|
|
if (data.stationCode) stations[cleanName].code = data.stationCode;
|
|
if (data.stationPlatform) stations[cleanName].platform = data.stationPlatform;
|
|
if (stations[cleanName].ws !== ws) {
|
|
stations[cleanName].ws = ws; // Update WS in case of reconnect
|
|
log('INFO', `Station WebSocket updated via heartbeat: "${cleanName}"`);
|
|
broadcastUpdate();
|
|
}
|
|
indexStation(stations[cleanName]);
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleStationRegister(ws, data) {
|
|
const { stationName, stationCode, stationPlatform } = data;
|
|
const cleanName = stationName.trim();
|
|
|
|
stations[cleanName] = {
|
|
name: cleanName,
|
|
code: stationCode,
|
|
platform: stationPlatform || getPlatformNumberFromName(cleanName),
|
|
lastSeen: Date.now(),
|
|
ws: ws // Keep reference if we need to send back
|
|
};
|
|
indexStation(stations[cleanName]);
|
|
|
|
log('SUCCESS', `Station registered: "${cleanName}" (${stationCode})`);
|
|
|
|
// Send ack
|
|
ws.send(JSON.stringify({
|
|
type: 'station_register_ack',
|
|
stationName: cleanName
|
|
}));
|
|
|
|
saveData();
|
|
broadcastUpdate();
|
|
}
|
|
|
|
function handleTrainArrival(data) {
|
|
let { trainId, stationName, arrivalTime, destination, source, schedule } = data;
|
|
stationName = getCorrectStationName(stationName);
|
|
const baseId = getBaseTrainId(trainId);
|
|
|
|
const cooldownKey = `${baseId}|${stationName}|arrival`;
|
|
const lastEventTime = trainEventCooldown.get(cooldownKey);
|
|
if (lastEventTime && Date.now() - lastEventTime < 45000) return;
|
|
trainEventCooldown.set(cooldownKey, Date.now());
|
|
|
|
log('INFO', `Train ${trainId} (Consist ${baseId}) arrived at ${stationName}`);
|
|
|
|
// Add or update active train using base ID to group cars
|
|
const existingIndex = activeTrains.findIndex(t => getBaseTrainId(t.trainId) === baseId);
|
|
|
|
// Create history entry
|
|
const historyEntry = {
|
|
station: stationName,
|
|
action: 'Arrival',
|
|
time: Date.now()
|
|
};
|
|
|
|
let trainData = {
|
|
trainId,
|
|
currentStation: stationName,
|
|
destination: destination || (existingIndex >= 0 ? activeTrains[existingIndex].destination : "Unknown"),
|
|
status: "At Station",
|
|
lastUpdate: Date.now(),
|
|
schedule: schedule || (existingIndex >= 0 ? activeTrains[existingIndex].schedule : null),
|
|
tripLogs: existingIndex >= 0 ? (activeTrains[existingIndex].tripLogs || []) : []
|
|
};
|
|
|
|
trainData.tripLogs.push(historyEntry);
|
|
|
|
if (existingIndex >= 0) {
|
|
activeTrains[existingIndex] = { ...activeTrains[existingIndex], ...trainData };
|
|
} else {
|
|
activeTrains.push(trainData);
|
|
}
|
|
|
|
broadcastUpdate();
|
|
}
|
|
|
|
function getBaseName(name) {
|
|
if (!name) return "";
|
|
return String(name).toLowerCase()
|
|
.replace(/^[\d\w]+[-_][\d\w]+\s+/, '') // Strip leading code "01-01 "
|
|
.replace(/\s*platform\s*[\d\w]+/gi, '')
|
|
.replace(/\s*track\s*[\d\w]+/gi, '')
|
|
.replace(/\s*站台\s*[\d\w]+/gi, '')
|
|
.replace(/\s*股道\s*[\d\w]+/gi, '')
|
|
.replace(/@/g, ' ')
|
|
.replace(/\s*(station|站|tread|track)$/gi, '')
|
|
.replace(/[\(\[].*?[\)\]]/g, ' ')
|
|
.replace(/\s+\d+$/g, '')
|
|
.replace(/[^\w\s\u4e00-\u9fa5]/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function isFuzzyMatch(str, pattern) {
|
|
if (!str || !pattern) return false;
|
|
const s = String(str).toLowerCase().trim();
|
|
const p = String(pattern).toLowerCase().trim();
|
|
|
|
if (s === p) return true;
|
|
|
|
// 1. Wildcard match
|
|
if (p.includes('*')) {
|
|
const regex = new RegExp('^' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*') + '$', 'i');
|
|
if (regex.test(s)) return true;
|
|
}
|
|
|
|
// 2. Base name comparison
|
|
const b1 = getBaseName(s);
|
|
const b2 = getBaseName(p);
|
|
if (b1 && b2 && b1 === b2) return true;
|
|
|
|
// 3. Substring match of base names (if one is long enough)
|
|
if (b1 && b2) {
|
|
if (b1.length >= 4 && b2.length >= 4) {
|
|
if (b1.includes(b2) || b2.includes(b1)) return true;
|
|
}
|
|
}
|
|
|
|
// 4. Stripped comparison
|
|
const s1 = s.replace(/[\s\-\._\(\)\[\]]/g, '');
|
|
const s2 = p.replace(/[\s\-\._\(\)\[\]]/g, '');
|
|
if (s1 && s2 && s1 === s2) return true;
|
|
|
|
// 5. Station code comparison
|
|
const c1 = getStationCodeFromName(s);
|
|
const c2 = getStationCodeFromName(p);
|
|
if (c1 && c2 && c1 === c2) {
|
|
// Only trust code match if it looks like a real code (contains numbers or is very short)
|
|
const isRealCode = (str) => /[\d]/.test(str) || str.length <= 4;
|
|
if (isRealCode(c1)) return true;
|
|
|
|
// If it's a long name-based code, also require base name similarity
|
|
if (b1 && b2 && (b1.includes(b2) || b2.includes(b1))) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function findStationByFlexibleName(targetName) {
|
|
if (!targetName) return null;
|
|
|
|
const directKeys = getStationRouteKeys(targetName);
|
|
for (const key of directKeys) {
|
|
const station = stationRouteIndex.get(key);
|
|
if (station) return station;
|
|
}
|
|
|
|
// 1. Try exact match
|
|
if (stations[targetName]) return stations[targetName];
|
|
|
|
// 2. Search through registered stations using fuzzy match
|
|
for (const [regName, data] of Object.entries(stations)) {
|
|
if (isFuzzyMatch(regName, targetName)) {
|
|
// log('DEBUG', `[Station-Search] Matched "${targetName}" to registered "${regName}"`);
|
|
return data;
|
|
}
|
|
}
|
|
|
|
// Log available stations for debugging
|
|
const available = Object.keys(stations).map(k => `"${k}" (base: "${getBaseName(k)}")`).join(', ');
|
|
log('DEBUG', `[Station-Search] Could not find match for "${targetName}" (base: "${getBaseName(targetName)}"). Available: [${available}]`);
|
|
return null;
|
|
}
|
|
|
|
function handleTrainDeparture(data) {
|
|
let { trainId, stationName, destination, schedule, source } = data;
|
|
stationName = getCorrectStationName(stationName);
|
|
const baseId = getBaseTrainId(trainId);
|
|
|
|
const cooldownKey = `${baseId}|${stationName}|departure`;
|
|
const lastEventTime = trainEventCooldown.get(cooldownKey);
|
|
if (lastEventTime && Date.now() - lastEventTime < 45000) return;
|
|
trainEventCooldown.set(cooldownKey, Date.now());
|
|
|
|
log('INFO', `Train ${trainId} (Consist ${baseId}) departed from ${stationName}`);
|
|
|
|
const existingIndex = activeTrains.findIndex(t => getBaseTrainId(t.trainId) === baseId);
|
|
if (existingIndex >= 0) {
|
|
const train = activeTrains[existingIndex];
|
|
train.status = "In Transit";
|
|
train.currentStation = "Transit (" + stationName + " -> " + (destination || "?") + ")";
|
|
train.lastUpdate = Date.now();
|
|
train.lastStation = stationName;
|
|
train.departureTime = Date.now();
|
|
train.destination = destination || train.destination;
|
|
train.schedule = schedule || train.schedule;
|
|
|
|
if (!train.tripLogs) train.tripLogs = [];
|
|
train.tripLogs.push({
|
|
station: stationName,
|
|
action: 'Departure',
|
|
time: Date.now()
|
|
});
|
|
}
|
|
broadcastUpdate();
|
|
}
|
|
|
|
function handlePlaySequence(ws, data) {
|
|
const { stationName, seq, requestId } = data;
|
|
const cleanStation = stationName.trim();
|
|
const correctStation = getCorrectStationName(cleanStation);
|
|
const streamName = getStationStreamName(cleanStation);
|
|
const hlsUrl = getStationHlsUrl(streamName);
|
|
|
|
log('INFO', `Playing sequence for ${correctStation}: ${seq.join(', ')}`);
|
|
|
|
const builtAudio = buildSequenceWavBuffer(seq);
|
|
if (builtAudio) {
|
|
const priority = Number.isFinite(Number(data.priority))
|
|
? Number(data.priority)
|
|
: (String(data.category || '').toLowerCase() === 'station' ? 0 : 1);
|
|
const interruptMode = data.interruptMode || getInterruptModeForPriority(priority);
|
|
const source = data.source || data.category || 'sequence';
|
|
|
|
const audioId = String(requestId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
|
|
enqueueStationAudioTask(cleanStation, ws, {
|
|
buffer: builtAudio.buffer,
|
|
audioFormat: builtAudio.audioFormat,
|
|
durationSec: builtAudio.durationSec,
|
|
priority,
|
|
interruptMode,
|
|
source,
|
|
audioId,
|
|
requeueOnPreempt: priority <= 0,
|
|
consoleStreamStart: { type: 'stream_start', station: cleanStation, seq: seq, streamName, hlsUrl },
|
|
consoleStreamEnd: { type: 'stream_end', station: cleanStation }
|
|
});
|
|
} else {
|
|
log('WARN', `Unable to build WAV sequence for ${cleanStation}`);
|
|
}
|
|
}
|
|
|
|
function handleStationTimeRequest(ws, data) {
|
|
// Simple logic: default to 60s or lookup schedule
|
|
const stationTime = 60;
|
|
ws.send(JSON.stringify({
|
|
type: 'station_time_response',
|
|
trainId: data.trainId,
|
|
stationTime: stationTime
|
|
}));
|
|
}
|
|
|
|
function broadcastToConsole(msg) {
|
|
wss.clients.forEach(client => {
|
|
if (client.readyState === WebSocket.OPEN && client.isConsole) {
|
|
client.send(JSON.stringify(msg));
|
|
}
|
|
});
|
|
}
|
|
|
|
function broadcastUpdate() {
|
|
const list = getMergedStationList().sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
const state = {
|
|
type: 'state_update',
|
|
stations: list,
|
|
activeTrains
|
|
};
|
|
broadcastToConsole(state);
|
|
}
|
|
|
|
app.get('/api/streams', (req, res) => {
|
|
const streams = [];
|
|
const seen = new Set();
|
|
const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim();
|
|
const hostRaw = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim();
|
|
const localBase = hostRaw ? `${proto}://${hostRaw}/hls` : '/hls';
|
|
const publicBaseResolved = HLS_OUTPUT_MODE === 'local'
|
|
? localBase
|
|
: String(process.env.STABROADCAST_PUBLIC_BASE || guessSrsHttpBase(req) || STABROADCAST_PUBLIC_BASE_DEFAULT);
|
|
|
|
Object.keys(stations).forEach(stationName => {
|
|
const st = stations[stationName];
|
|
const streamName = getStationStreamName(stationName);
|
|
const state = getOrCreateHlsStreamState(streamName);
|
|
state.stationName = stationName;
|
|
state.stationCode = getPrimaryStationCode(st?.code);
|
|
state.hlsUrl = HLS_OUTPUT_MODE === 'local'
|
|
? getLocalHlsUrlForRequest(streamName, req)
|
|
: getStationHlsUrlForRequest(streamName, req);
|
|
seen.add(streamName);
|
|
|
|
streams.push({
|
|
streamName,
|
|
stationName: state.stationName,
|
|
stationCode: state.stationCode,
|
|
hlsUrl: state.hlsUrl,
|
|
status: state.status,
|
|
lastStartAt: state.lastStartAt,
|
|
lastEndAt: state.lastEndAt,
|
|
lastSeq: state.lastSeq,
|
|
lastError: state.lastError,
|
|
lastExitCode: state.lastExitCode
|
|
});
|
|
});
|
|
|
|
Object.values(hlsStreams).forEach(state => {
|
|
if (seen.has(state.streamName)) return;
|
|
streams.push({
|
|
streamName: state.streamName,
|
|
stationName: state.stationName,
|
|
stationCode: state.stationCode,
|
|
hlsUrl: HLS_OUTPUT_MODE === 'local'
|
|
? getLocalHlsUrlForRequest(state.streamName, req)
|
|
: getStationHlsUrlForRequest(state.streamName, req),
|
|
status: state.status,
|
|
lastStartAt: state.lastStartAt,
|
|
lastEndAt: state.lastEndAt,
|
|
lastSeq: state.lastSeq,
|
|
lastError: state.lastError,
|
|
lastExitCode: state.lastExitCode
|
|
});
|
|
});
|
|
|
|
streams.sort((a, b) => String(a.streamName).localeCompare(String(b.streamName)));
|
|
|
|
res.json({
|
|
ok: true,
|
|
hlsEnable: HLS_ENABLE,
|
|
streamMode: HLS_STREAM_MODE,
|
|
publicBase: publicBaseResolved,
|
|
srsRtmpBase: SRS_RTMP_BASE,
|
|
streams
|
|
});
|
|
});
|
|
|
|
app.get('/stablist', (req, res) => {
|
|
res.redirect('/stablist.html');
|
|
});
|
|
|
|
// Routes for direct API access if needed
|
|
app.get('/', (req, res) => {
|
|
const host = String(req.headers.host || '').toLowerCase();
|
|
if (host.startsWith('stablist.fse-media.group')) {
|
|
res.sendFile(path.join(__dirname, 'public', 'stablist.html'));
|
|
return;
|
|
}
|
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
});
|
|
|
|
function convertToDfpwm(filePath) {
|
|
try {
|
|
if (!filePath.toLowerCase().endsWith('.wav')) return;
|
|
|
|
const rawPath = filePath.replace(/\.wav$/i, '.raw');
|
|
const dfpwmPath = filePath.replace(/\.wav$/i, '.dfpwm');
|
|
|
|
// Convert to raw signed 8-bit 48kHz mono using FFmpeg
|
|
// -y: overwrite output
|
|
// -v quiet: suppress output
|
|
// -f s8: signed 8-bit pcm
|
|
// -ac 1: mono
|
|
// -ar 48000: 48kHz
|
|
execSync(`ffmpeg -y -v quiet -i "${filePath}" -f s8 -acodec pcm_s8 -ac 1 -ar 48000 "${rawPath}"`);
|
|
|
|
if (fs.existsSync(rawPath)) {
|
|
const buffer = fs.readFileSync(rawPath);
|
|
|
|
// dfpwm.Encoder.encode requires a Buffer in Node.js
|
|
const encoder = new dfpwm.Encoder();
|
|
|
|
// We need to pass the raw buffer to the encoder. The buffer already contains 8-bit PCM data.
|
|
// Some versions of dfpwm throw "Argument #1 must be a Buffer" if it's an Array or TypedArray.
|
|
const encoded = encoder.encode(buffer);
|
|
|
|
fs.writeFileSync(dfpwmPath, encoded);
|
|
fs.unlinkSync(rawPath); // Clean up temp file
|
|
|
|
log('SUCCESS', `Converted ${path.basename(filePath)} to DFPWM (FFmpeg)`);
|
|
} else {
|
|
log('ERROR', `FFmpeg failed to generate raw file for ${path.basename(filePath)}`);
|
|
}
|
|
|
|
} catch (e) {
|
|
log('ERROR', `DFPWM conversion failed for ${path.basename(filePath)}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// Routes
|
|
app.get('/api/version', (req, res) => {
|
|
res.json({ version: audioVersion });
|
|
});
|
|
|
|
app.post('/api/publish-version', (req, res) => {
|
|
const version = normalizeVersionString(req.body?.version) || String(Date.now());
|
|
const snap = snapshotFiles();
|
|
const prev = versions.length > 0 ? versions[versions.length - 1].snapshot : null;
|
|
const changes = diffSnapshots(prev, snap);
|
|
const entry = {
|
|
id: String(Date.now()),
|
|
version,
|
|
note: normalizeVersionString(req.body?.note),
|
|
createdAt: Date.now(),
|
|
snapshot: snap,
|
|
changes
|
|
};
|
|
versions.push(entry);
|
|
audioVersion = version;
|
|
saveData();
|
|
log('INFO', `Audio version published: ${version}`);
|
|
res.json({ success: true, version });
|
|
});
|
|
|
|
app.get('/api/versions', (req, res) => {
|
|
const list = versions
|
|
.slice()
|
|
.reverse()
|
|
.map(v => ({
|
|
id: v.id,
|
|
version: v.version,
|
|
note: v.note || '',
|
|
createdAt: v.createdAt,
|
|
changes: v.changes || { added: [], modified: [], deleted: [] }
|
|
}));
|
|
res.json({ success: true, activeVersion: audioVersion, versions: list });
|
|
});
|
|
|
|
app.post('/api/versions', (req, res) => {
|
|
const version = normalizeVersionString(req.body?.version) || String(Date.now());
|
|
const snap = snapshotFiles();
|
|
const prev = versions.length > 0 ? versions[versions.length - 1].snapshot : null;
|
|
const changes = diffSnapshots(prev, snap);
|
|
const entry = {
|
|
id: String(Date.now()),
|
|
version,
|
|
note: normalizeVersionString(req.body?.note),
|
|
createdAt: Date.now(),
|
|
snapshot: snap,
|
|
changes
|
|
};
|
|
versions.push(entry);
|
|
audioVersion = version;
|
|
saveData();
|
|
log('INFO', `Audio version published: ${version}`);
|
|
res.json({ success: true, version, id: entry.id });
|
|
});
|
|
|
|
app.put('/api/versions/:id', (req, res) => {
|
|
const id = req.params.id;
|
|
const idx = versions.findIndex(v => v.id === id);
|
|
if (idx < 0) return res.status(404).json({ success: false, message: 'Version not found' });
|
|
|
|
const wasActive = versions[idx].version === audioVersion;
|
|
const nextVersion = normalizeVersionString(req.body?.version);
|
|
if (nextVersion) versions[idx].version = nextVersion;
|
|
const nextNote = normalizeVersionString(req.body?.note);
|
|
if (req.body?.note !== undefined) versions[idx].note = nextNote;
|
|
|
|
if (wasActive) audioVersion = versions[idx].version;
|
|
|
|
saveData();
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.get('/api/versions/:id/changes', (req, res) => {
|
|
const id = req.params.id;
|
|
const entry = versions.find(v => v.id === id);
|
|
if (!entry) return res.status(404).json({ success: false, message: 'Version not found' });
|
|
res.json({ success: true, changes: entry.changes || { added: [], modified: [], deleted: [] } });
|
|
});
|
|
|
|
app.get('/api/versions/:id/files', (req, res) => {
|
|
const id = req.params.id;
|
|
const entry = versions.find(v => v.id === id);
|
|
if (!entry) return res.status(404).json({ success: false, message: 'Version not found' });
|
|
res.json({ success: true, files: entry.snapshot || {} });
|
|
});
|
|
|
|
app.post('/api/save-data', (req, res) => {
|
|
saveData();
|
|
log('INFO', 'Manual data save triggered');
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.post('/api/upload/list', upload.single('listFile'), (req, res) => {
|
|
log('SUCCESS', 'Updated list.txt');
|
|
res.json({ success: true, message: 'list.txt updated' });
|
|
});
|
|
|
|
app.post('/api/upload/general-audio', upload.array('generalAudioFiles'), (req, res) => {
|
|
const files = req.files || [];
|
|
log('SUCCESS', `Uploaded ${files.length} general audio files`);
|
|
res.json({ success: true, message: `${files.length} general audio files uploaded` });
|
|
});
|
|
|
|
app.post('/api/upload/station-audio', upload.array('stationAudioFiles'), (req, res) => {
|
|
const files = req.files || [];
|
|
log('SUCCESS', `Uploaded ${files.length} station audio files`);
|
|
res.json({ success: true, message: `${files.length} station audio files uploaded` });
|
|
});
|
|
|
|
app.get('/api/files', (req, res) => {
|
|
res.json({ files: getManagedFiles() });
|
|
});
|
|
|
|
app.delete('/api/files', (req, res) => {
|
|
const filePath = req.query.path;
|
|
if (!filePath) return res.status(400).json({ success: false, message: 'Path required' });
|
|
|
|
// Security check: prevent directory traversal
|
|
const safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '');
|
|
const fullPath = path.join(__dirname, 'public', safePath);
|
|
|
|
if (!fullPath.startsWith(uploadDir)) {
|
|
return res.status(403).json({ success: false, message: 'Invalid path' });
|
|
}
|
|
|
|
if (fs.existsSync(fullPath)) {
|
|
try {
|
|
fs.unlinkSync(fullPath);
|
|
log('INFO', `Deleted file: ${safePath}`);
|
|
|
|
// Also try to delete counterpart (.dfpwm if .wav deleted, or vice versa)
|
|
if (safePath.endsWith('.wav')) {
|
|
const dfpwmPath = fullPath.replace(/\.wav$/, '.dfpwm');
|
|
if (fs.existsSync(dfpwmPath)) fs.unlinkSync(dfpwmPath);
|
|
} else if (safePath.endsWith('.dfpwm')) {
|
|
const wavPath = fullPath.replace(/\.dfpwm$/, '.wav');
|
|
if (fs.existsSync(wavPath)) fs.unlinkSync(wavPath);
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (e) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
} else {
|
|
res.status(404).json({ success: false, message: 'File not found' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/stations', (req, res) => {
|
|
const list = getMergedStationList()
|
|
.sort((a, b) => String(a.code || '').localeCompare(String(b.code || '')) || String(a.name || '').localeCompare(String(b.name || '')));
|
|
res.json({ success: true, stations: list });
|
|
});
|
|
|
|
app.post('/api/stations', express.json(), (req, res) => {
|
|
const name = String(req.body?.name || '').trim();
|
|
const code = req.body?.code === undefined ? '' : String(req.body.code).trim();
|
|
if (!name) return res.status(400).json({ success: false, message: 'Station name required' });
|
|
if (stations[name]) return res.status(409).json({ success: false, message: 'Station already exists' });
|
|
|
|
stations[name] = {
|
|
name,
|
|
code,
|
|
platform: getPlatformNumberFromName(name),
|
|
lastSeen: Date.now()
|
|
};
|
|
indexStation(stations[name]);
|
|
saveData();
|
|
broadcastUpdate();
|
|
res.json({ success: true, station: { name, code, lastSeen: stations[name].lastSeen } });
|
|
});
|
|
|
|
app.put('/api/stations/:name', express.json(), (req, res) => {
|
|
const name = req.params.name;
|
|
const st = stations[name];
|
|
if (!st) return res.status(404).json({ success: false, message: 'Station not found' });
|
|
|
|
const newName = req.body?.newName === undefined ? '' : String(req.body.newName).trim();
|
|
const nextCode = req.body?.code === undefined ? undefined : String(req.body.code).trim();
|
|
|
|
let targetName = name;
|
|
if (newName && newName !== name) {
|
|
if (stations[newName]) return res.status(409).json({ success: false, message: 'New station name already exists' });
|
|
stations[newName] = { ...st, name: newName };
|
|
unindexStation(stations[name]);
|
|
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();
|
|
indexStation(stations[targetName]);
|
|
|
|
saveData();
|
|
broadcastUpdate();
|
|
res.json({ success: true, station: { name: stations[targetName].name, code: stations[targetName].code, lastSeen: stations[targetName].lastSeen } });
|
|
});
|
|
|
|
app.delete('/api/stations/:name', (req, res) => {
|
|
const name = req.params.name;
|
|
if (stations[name]) {
|
|
unindexStation(stations[name]);
|
|
delete stations[name];
|
|
log('INFO', `Deleted station: ${name}`);
|
|
saveData();
|
|
broadcastUpdate();
|
|
res.json({ success: true });
|
|
} else {
|
|
res.status(404).json({ success: false, message: 'Station not found' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/streams/:streamName', (req, res) => {
|
|
const streamName = String(req.params.streamName || '').trim();
|
|
if (!streamName) return res.status(400).json({ success: false, message: 'Stream name required' });
|
|
const state = hlsStreams[streamName];
|
|
if (!state) return res.status(404).json({ success: false, message: 'Stream not found' });
|
|
if (state.currentProcess) {
|
|
try { state.currentProcess.kill('SIGKILL'); } catch {}
|
|
}
|
|
delete hlsStreams[streamName];
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.delete('/api/trains/:id', (req, res) => {
|
|
const id = req.params.id;
|
|
const initialLength = activeTrains.length;
|
|
activeTrains = activeTrains.filter(t => t.trainId !== id);
|
|
|
|
if (activeTrains.length < initialLength) {
|
|
log('INFO', `Deleted train: ${id}`);
|
|
saveData();
|
|
broadcastUpdate();
|
|
res.json({ success: true });
|
|
} else {
|
|
res.status(404).json({ success: false, message: 'Train not found' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/files/content', (req, res) => {
|
|
const filePath = req.query.path;
|
|
if (!filePath) return res.status(400).json({ success: false, message: 'Path required' });
|
|
|
|
const safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '');
|
|
const fullPath = path.join(__dirname, 'public', safePath);
|
|
|
|
if (!fullPath.startsWith(uploadDir)) {
|
|
return res.status(403).json({ success: false, message: 'Invalid path' });
|
|
}
|
|
|
|
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 safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '');
|
|
const fullPath = path.join(__dirname, 'public', safePath);
|
|
|
|
if (!fullPath.startsWith(uploadDir)) {
|
|
return res.status(403).json({ success: false, message: 'Invalid path' });
|
|
}
|
|
|
|
try {
|
|
fs.writeFileSync(fullPath, content, 'utf8');
|
|
log('INFO', `Updated file: ${safePath}`);
|
|
res.json({ success: true });
|
|
} catch (e) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
// TTS Generation Proxy
|
|
const tls = require('tls');
|
|
|
|
function requestViaProxy(urlStr, options, proxyUrlStr) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(urlStr);
|
|
const proxyUrl = new URL(proxyUrlStr);
|
|
|
|
const proxyReq = http.request({
|
|
host: proxyUrl.hostname,
|
|
port: proxyUrl.port,
|
|
method: 'CONNECT',
|
|
path: url.hostname + ':' + (url.port || 443),
|
|
});
|
|
|
|
proxyReq.on('connect', (res, socket, head) => {
|
|
if (res.statusCode !== 200) {
|
|
return reject(new Error('Proxy connection failed: ' + res.statusCode));
|
|
}
|
|
const tlsSocket = tls.connect({
|
|
socket: socket,
|
|
servername: url.hostname
|
|
}, () => {
|
|
const req = require('https').request({
|
|
host: url.hostname,
|
|
port: url.port || 443,
|
|
path: url.pathname + url.search,
|
|
method: options.method || 'GET',
|
|
headers: options.headers,
|
|
createConnection: () => tlsSocket
|
|
}, (res) => {
|
|
let body = [];
|
|
res.on('data', chunk => body.push(chunk));
|
|
res.on('end', () => resolve({
|
|
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
status: res.statusCode,
|
|
buffer: Buffer.concat(body),
|
|
text: Buffer.concat(body).toString('utf8')
|
|
}));
|
|
});
|
|
|
|
req.on('error', reject);
|
|
if (options.body) req.write(options.body);
|
|
req.end();
|
|
});
|
|
tlsSocket.on('error', reject);
|
|
});
|
|
|
|
proxyReq.on('error', reject);
|
|
proxyReq.end();
|
|
});
|
|
}
|
|
|
|
function requestDirect(urlStr, options) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(urlStr);
|
|
const req = require('https').request({
|
|
host: url.hostname,
|
|
port: url.port || 443,
|
|
path: url.pathname + url.search,
|
|
method: options.method || 'GET',
|
|
headers: options.headers
|
|
}, (res) => {
|
|
let body = [];
|
|
res.on('data', chunk => body.push(chunk));
|
|
res.on('end', () => resolve({
|
|
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
status: res.statusCode,
|
|
buffer: Buffer.concat(body),
|
|
text: Buffer.concat(body).toString('utf8')
|
|
}));
|
|
});
|
|
|
|
req.on('error', reject);
|
|
if (options.body) req.write(options.body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
async function generateTtsWavToTemp(text, reference_id) {
|
|
const proxyUrl = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy;
|
|
|
|
const requestBody = JSON.stringify({
|
|
text: text,
|
|
reference_id: reference_id,
|
|
format: "wav"
|
|
});
|
|
|
|
const options = {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649',
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(requestBody)
|
|
},
|
|
body: requestBody
|
|
};
|
|
|
|
const response = proxyUrl
|
|
? await requestViaProxy('https://api.fish.audio/v1/tts', options, proxyUrl)
|
|
: await requestDirect('https://api.fish.audio/v1/tts', options);
|
|
|
|
if (!response.ok) {
|
|
const msg = (response.text && String(response.text).trim().length > 0)
|
|
? response.text
|
|
: `Upstream API error (HTTP ${response.status})`;
|
|
const err = new Error(msg);
|
|
err.status = response.status || 500;
|
|
throw err;
|
|
}
|
|
|
|
const tempName = `temp_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
|
|
const tempPath = path.join(uploadDir, tempName);
|
|
fs.writeFileSync(tempPath, response.buffer);
|
|
|
|
return { tempName, tempPath, url: `/uploads/${tempName}` };
|
|
}
|
|
|
|
app.post('/api/tts/generate', express.json(), async (req, res) => {
|
|
const { text, reference_id } = req.body;
|
|
if (!text || !reference_id) return res.status(400).json({ success: false, message: 'Missing text or reference_id' });
|
|
|
|
try {
|
|
const proxyUrl = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy;
|
|
console.log(`[TTS] Sending request for text: "${text}"${proxyUrl ? ` via proxy: ${proxyUrl}` : ''}`);
|
|
|
|
const requestBody = JSON.stringify({
|
|
text: text,
|
|
reference_id: reference_id,
|
|
format: "wav"
|
|
});
|
|
|
|
const options = {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649',
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(requestBody)
|
|
},
|
|
body: requestBody
|
|
};
|
|
|
|
const response = proxyUrl
|
|
? await requestViaProxy('https://api.fish.audio/v1/tts', options, proxyUrl)
|
|
: await requestDirect('https://api.fish.audio/v1/tts', options);
|
|
|
|
if (!response.ok) {
|
|
const msg = (response.text && String(response.text).trim().length > 0)
|
|
? response.text
|
|
: `Upstream API error (HTTP ${response.status})`;
|
|
console.error(`[TTS] API Error Status ${response.status}: ${msg}`);
|
|
return res.status(response.status || 500).json({ success: false, message: msg });
|
|
}
|
|
|
|
// Save to a temporary file
|
|
const tempName = `temp_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
|
|
const tempPath = path.join(uploadDir, tempName);
|
|
fs.writeFileSync(tempPath, response.buffer);
|
|
|
|
console.log(`[TTS] Success: saved to ${tempName}`);
|
|
res.json({ success: true, url: `/uploads/${tempName}`, tempName });
|
|
} catch (e) {
|
|
console.error("[TTS] Generation Error:", e);
|
|
const msg = (e && e.message && String(e.message).trim().length > 0)
|
|
? e.message
|
|
: (e && e.code ? String(e.code) : 'Unknown error');
|
|
res.status(500).json({ success: false, message: msg + (e && e.cause && e.cause.message ? ` (${e.cause.message})` : '') });
|
|
}
|
|
});
|
|
|
|
// Endpoint to receive temp files from client
|
|
app.post('/api/tts/upload_temp', upload.fields([{ name: 'zhAudio', maxCount: 1 }, { name: 'enAudio', maxCount: 1 }]), (req, res) => {
|
|
try {
|
|
const zhFile = req.files['zhAudio'] ? req.files['zhAudio'][0] : null;
|
|
const enFile = req.files['enAudio'] ? req.files['enAudio'][0] : null;
|
|
|
|
if (!zhFile || !enFile) {
|
|
return res.status(400).json({ success: false, message: 'Missing audio files' });
|
|
}
|
|
|
|
// Rename to secure temp names
|
|
const zhTempName = `temp_zh_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
|
|
const enTempName = `temp_en_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
|
|
|
|
const zhTempPath = path.join(uploadDir, zhTempName);
|
|
const enTempPath = path.join(uploadDir, enTempName);
|
|
|
|
fs.renameSync(zhFile.path, zhTempPath);
|
|
fs.renameSync(enFile.path, enTempPath);
|
|
|
|
res.json({
|
|
success: true,
|
|
zhTempName,
|
|
enTempName,
|
|
zhUrl: `/uploads/${zhTempName}`,
|
|
enUrl: `/uploads/${enTempName}`
|
|
});
|
|
} catch (e) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
// Import TTS temp files to station folder
|
|
app.post('/api/tts/import', express.json(), (req, res) => {
|
|
const { stationCode, zhTemp, enTemp } = req.body;
|
|
if (!stationCode || !zhTemp || !enTemp) return res.status(400).json({ success: false, message: 'Missing parameters' });
|
|
|
|
const zhSource = path.join(uploadDir, zhTemp);
|
|
const enSource = path.join(uploadDir, enTemp);
|
|
const safeStationCode = String(stationCode).replace(/\//g, '-');
|
|
const zhDest = path.join(audioStationDir, `ch-${safeStationCode}.wav`);
|
|
const enDest = path.join(audioStationDir, `en-${safeStationCode}.wav`);
|
|
|
|
try {
|
|
if (fs.existsSync(zhSource)) {
|
|
fs.copyFileSync(zhSource, zhDest);
|
|
fs.unlinkSync(zhSource);
|
|
}
|
|
if (fs.existsSync(enSource)) {
|
|
fs.copyFileSync(enSource, enDest);
|
|
fs.unlinkSync(enSource);
|
|
}
|
|
log('SUCCESS', `Imported generated audio for station ${stationCode}`);
|
|
res.json({ success: true });
|
|
} catch (e) {
|
|
log('ERROR', `Failed to import audio: ${e.message}`);
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
function safeFilename(name) {
|
|
const base = path.basename(String(name || '').trim());
|
|
if (!base || base.includes('/') || base.includes('\\')) return null;
|
|
return base;
|
|
}
|
|
|
|
function findWavByBaseName(baseName) {
|
|
const safeBase = String(baseName || '').trim().replace(/\.wav$/i, '');
|
|
if (!safeBase) return null;
|
|
const candidates = [
|
|
path.join(audioGeneralDir, `${safeBase}.wav`),
|
|
path.join(audioStationDir, `${safeBase}.wav`),
|
|
path.join(uploadDir, `${safeBase}.wav`)
|
|
];
|
|
return candidates.find(p => fs.existsSync(p)) || null;
|
|
}
|
|
|
|
function buildTempBroadcastFromWav(prefixWavPath, contentWavPath) {
|
|
const broadcastId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
const outBase = `temp_${broadcastId}`;
|
|
const outWavPath = path.join(tempBroadcastDir, `${outBase}.wav`);
|
|
|
|
const filterComplex = [
|
|
'[0:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a0]',
|
|
'[1:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a1]',
|
|
'[a0][a1]concat=n=2:v=0:a=1[a]'
|
|
].join(';');
|
|
|
|
execSync(`ffmpeg -hide_banner -loglevel error -y -i "${prefixWavPath}" -i "${contentWavPath}" -filter_complex "${filterComplex}" -map "[a]" -ac 1 -ar 48000 -c:a pcm_s16le "${outWavPath}"`);
|
|
const wavBuf = fs.readFileSync(outWavPath);
|
|
const durationSec = getWavDurationSec(wavBuf) || 0;
|
|
|
|
return {
|
|
broadcastId,
|
|
wavPath: outWavPath,
|
|
durationSec
|
|
};
|
|
}
|
|
|
|
app.post('/api/temp-broadcast/build', (req, res) => {
|
|
const contentTempName = safeFilename(req.body?.contentTempName);
|
|
const prefixName = String(req.body?.prefixName || 'lsts').trim();
|
|
if (!contentTempName) return res.status(400).json({ success: false, message: 'Invalid contentTempName' });
|
|
|
|
const contentPath = path.join(uploadDir, contentTempName);
|
|
if (!contentPath.startsWith(uploadDir)) return res.status(403).json({ success: false, message: 'Invalid path' });
|
|
if (!fs.existsSync(contentPath)) return res.status(404).json({ success: false, message: 'Content temp wav not found' });
|
|
|
|
const prefixWavPath = findWavByBaseName(prefixName);
|
|
if (!prefixWavPath) return res.status(404).json({ success: false, message: `Prefix wav not found: ${prefixName}.wav` });
|
|
|
|
try {
|
|
const built = buildTempBroadcastFromWav(prefixWavPath, contentPath);
|
|
res.json({
|
|
success: true,
|
|
broadcastId: built.broadcastId,
|
|
wavUrl: `/uploads/temp-broadcast/${path.basename(built.wavPath)}`,
|
|
durationSec: built.durationSec
|
|
});
|
|
} catch (e) {
|
|
res.status(500).json({ success: false, message: e?.message || String(e) });
|
|
}
|
|
});
|
|
|
|
app.post('/api/temp-broadcast/compose', express.json(), async (req, res) => {
|
|
const text = String(req.body?.text || '').trim();
|
|
const reference_id = String(req.body?.reference_id || DEFAULT_ZH_MODEL).trim();
|
|
const prefixName = String(req.body?.prefixName || 'lsts').trim();
|
|
if (!text) return res.status(400).json({ success: false, message: 'text required' });
|
|
if (!reference_id) return res.status(400).json({ success: false, message: 'reference_id required' });
|
|
|
|
const prefixWavPath = findWavByBaseName(prefixName);
|
|
if (!prefixWavPath) return res.status(404).json({ success: false, message: `Prefix wav not found: ${prefixName}.wav` });
|
|
|
|
let ttsTempPath = null;
|
|
try {
|
|
const tts = await generateTtsWavToTemp(text, reference_id);
|
|
ttsTempPath = tts.tempPath;
|
|
const built = buildTempBroadcastFromWav(prefixWavPath, ttsTempPath);
|
|
try { fs.unlinkSync(ttsTempPath); } catch {}
|
|
ttsTempPath = null;
|
|
|
|
res.json({
|
|
success: true,
|
|
broadcastId: built.broadcastId,
|
|
wavUrl: `/uploads/temp-broadcast/${path.basename(built.wavPath)}`,
|
|
durationSec: built.durationSec
|
|
});
|
|
} catch (e) {
|
|
if (ttsTempPath) {
|
|
try { fs.unlinkSync(ttsTempPath); } catch {}
|
|
}
|
|
res.status(Number(e?.status) || 500).json({ success: false, message: e?.message || String(e) });
|
|
}
|
|
});
|
|
|
|
app.post('/api/temp-broadcast/publish', (req, res) => {
|
|
const broadcastId = String(req.body?.broadcastId || '').trim();
|
|
const urgency = String(req.body?.urgency || 'normal').trim().toLowerCase();
|
|
const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : [];
|
|
|
|
if (!broadcastId) return res.status(400).json({ success: false, message: 'broadcastId required' });
|
|
if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' });
|
|
if (urgency !== 'normal' && urgency !== 'emergency') return res.status(400).json({ success: false, message: 'Invalid urgency' });
|
|
|
|
const wavPath = path.join(tempBroadcastDir, `temp_${broadcastId}.wav`);
|
|
if (!fs.existsSync(wavPath)) return res.status(404).json({ success: false, message: 'Temp broadcast not found (wav)' });
|
|
|
|
const wavBuf = fs.readFileSync(wavPath);
|
|
const durationSec = getWavDurationSec(wavBuf) || 0;
|
|
const priority = urgency === 'emergency' ? 2 : 0;
|
|
const interruptMode = urgency === 'emergency' ? 'all' : 'none';
|
|
const source = urgency === 'emergency' ? 'temp_emergency' : 'temp_normal';
|
|
const requeueOnPreempt = urgency !== 'emergency';
|
|
|
|
let sent = 0;
|
|
let failed = 0;
|
|
const results = [];
|
|
|
|
for (const rawName of stationsReq) {
|
|
const name = String(rawName || '').trim();
|
|
if (!name) continue;
|
|
const ws = getStationWs(name);
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
failed++;
|
|
results.push({ stationName: name, ok: false, reason: 'offline' });
|
|
continue;
|
|
}
|
|
enqueueStationAudioTask(name, ws, {
|
|
buffer: wavBuf,
|
|
audioFormat: 'wav',
|
|
durationSec,
|
|
priority,
|
|
interruptMode,
|
|
source,
|
|
audioId: `temp-${broadcastId}-${name}-${Date.now()}`,
|
|
requeueOnPreempt,
|
|
consoleStreamStart: { type: 'stream_start', station: name, seq: [`TEMP:${broadcastId}`], streamName: getStationStreamName(name), hlsUrl: getStationHlsUrl(getStationStreamName(name)) },
|
|
consoleStreamEnd: { type: 'stream_end', station: name }
|
|
});
|
|
sent++;
|
|
results.push({ stationName: name, ok: true });
|
|
}
|
|
|
|
res.json({ success: true, sent, failed, results });
|
|
});
|
|
|
|
app.get('/api/music/tracks', (req, res) => {
|
|
res.json({ success: true, tracks: listMusicTracks() });
|
|
});
|
|
|
|
app.post('/api/music/start', express.json(), (req, res) => {
|
|
const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : [];
|
|
const track = normalizeBaseName(req.body?.track);
|
|
const loop = Boolean(req.body?.loop);
|
|
|
|
if (!track) return res.status(400).json({ success: false, message: 'track required' });
|
|
if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' });
|
|
|
|
let playbackAudio;
|
|
try {
|
|
playbackAudio = getPlaybackAudioByBaseName(track);
|
|
} catch (e) {
|
|
return res.status(404).json({ success: false, message: e?.message || String(e) });
|
|
}
|
|
|
|
let sent = 0;
|
|
let failed = 0;
|
|
const results = [];
|
|
|
|
for (const rawName of stationsReq) {
|
|
const name = String(rawName || '').trim();
|
|
if (!name) continue;
|
|
const ws = getStationWs(name);
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
failed++;
|
|
results.push({ stationName: name, ok: false, reason: 'offline' });
|
|
continue;
|
|
}
|
|
|
|
stopStationMusic(name, ws);
|
|
stationMusicStates.set(name, { enabled: true, track, loop });
|
|
|
|
const enqueueOnce = () => {
|
|
const currentWs = getStationWs(name);
|
|
if (!currentWs || currentWs.readyState !== WebSocket.OPEN) {
|
|
// If offline, retry later instead of giving up
|
|
setTimeout(enqueueOnce, 5000);
|
|
return;
|
|
}
|
|
|
|
enqueueStationAudioTask(name, currentWs, {
|
|
buffer: playbackAudio.buffer,
|
|
audioFormat: playbackAudio.audioFormat,
|
|
durationSec: playbackAudio.durationSec,
|
|
priority: -1,
|
|
interruptMode: 'none',
|
|
source: 'music',
|
|
audioId: `music-${track}-${name}-${Date.now()}`,
|
|
requeueOnPreempt: true,
|
|
consoleStreamStart: { type: 'stream_start', station: name, seq: [`MUSIC:${track}`], streamName: getStationStreamName(name), hlsUrl: getStationHlsUrl(getStationStreamName(name)) },
|
|
consoleStreamEnd: { type: 'stream_end', station: name },
|
|
onDone: (info) => {
|
|
const st = stationMusicStates.get(name);
|
|
if (!st || !st.enabled) return;
|
|
if (st.track !== track) return;
|
|
if (!st.loop) return;
|
|
// Only stop if cancelled by something else (like stopMusic or higher priority)
|
|
if (info && info.cancelled) {
|
|
// If it was just preempted, enqueueStationAudioTask handles the re-insertion
|
|
// If it was cancelled by stopStationMusic, st.enabled will be false
|
|
return;
|
|
}
|
|
enqueueOnce();
|
|
}
|
|
});
|
|
};
|
|
|
|
enqueueOnce();
|
|
sent++;
|
|
results.push({ stationName: name, ok: true });
|
|
}
|
|
|
|
res.json({ success: true, sent, failed, results });
|
|
});
|
|
|
|
app.post('/api/music/stop', express.json(), (req, res) => {
|
|
const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : [];
|
|
if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' });
|
|
|
|
let stopped = 0;
|
|
const results = [];
|
|
|
|
for (const rawName of stationsReq) {
|
|
const name = String(rawName || '').trim();
|
|
if (!name) continue;
|
|
const ws = getStationWs(name);
|
|
stationMusicStates.set(name, { enabled: false, track: null, loop: false });
|
|
stopStationMusic(name, ws);
|
|
stopped++;
|
|
results.push({ stationName: name, ok: true });
|
|
}
|
|
|
|
res.json({ success: true, stopped, results });
|
|
});
|
|
|
|
// Error handling middleware
|
|
app.use((err, req, res, next) => {
|
|
log('ERROR', `Unhandled error: ${err.message}`);
|
|
res.status(err.status || 500).json({
|
|
success: false,
|
|
message: err.message || 'Internal Server Error'
|
|
});
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
loadData();
|
|
startCtmClient();
|
|
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}`);
|
|
});
|