2912 lines
104 KiB
JavaScript
2912 lines
104 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 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 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(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 === '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);
|
|
}
|
|
|
|
app.get('/media/audio/general/:file', (req, res) => {
|
|
const name = path.basename(String(req.params.file || ''));
|
|
sendBinaryFileFromCandidates(res, getBeepAudioDirs().map(dir => path.join(dir, name)));
|
|
});
|
|
app.get('/media/audio/beep/:file', (req, res) => {
|
|
const name = path.basename(String(req.params.file || ''));
|
|
sendBinaryFileFromCandidates(res, getBeepAudioDirs().map(dir => path.join(dir, name)));
|
|
});
|
|
app.get('/media/audio/music/:file', (req, res) => {
|
|
const name = path.basename(String(req.params.file || ''));
|
|
sendBinaryFile(res, path.join(audioMusicDir, name));
|
|
});
|
|
app.get('/media/audio/voice/:file', (req, res) => {
|
|
const name = path.basename(String(req.params.file || ''));
|
|
sendBinaryFile(res, path.join(audioVoiceDir, name));
|
|
});
|
|
|
|
app.get('/media/audio/station/:file', (req, res) => {
|
|
const name = path.basename(String(req.params.file || ''));
|
|
sendBinaryFile(res, path.join(audioStationDir, name));
|
|
});
|
|
|
|
app.get('/media/temp/:file', (req, res) => {
|
|
const name = path.basename(String(req.params.file || ''));
|
|
sendBinaryFile(res, path.join(tempBroadcastDir, name));
|
|
});
|
|
|
|
app.get('/media', (req, res) => {
|
|
res.json({
|
|
success: true,
|
|
message: 'Use a concrete media path',
|
|
routes: [
|
|
'/media/audio/music/:file',
|
|
'/media/audio/voice/: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)
|
|
let stations = {};
|
|
let activeTrains = [];
|
|
let stationLogs = [];
|
|
let audioVersion = String(Date.now());
|
|
let versions = [];
|
|
|
|
const DATA_FILE = path.join(__dirname, 'server-data.json');
|
|
|
|
const stationAudioQueues = new Map();
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function getInterruptModeForPriority(priority) {
|
|
if (priority >= 2) return 'all';
|
|
if (priority >= 1) return 'ifLower';
|
|
return 'none';
|
|
}
|
|
|
|
function insertTaskByPriority(queue, task) {
|
|
const p = Number(task?.priority || 0);
|
|
let idx = 0;
|
|
while (idx < queue.length) {
|
|
const q = queue[idx];
|
|
const qp = Number(q?.priority || 0);
|
|
if (p > qp) break;
|
|
idx++;
|
|
}
|
|
queue.splice(idx, 0, task);
|
|
}
|
|
|
|
function getStationData(stationName) {
|
|
const cleanName = String(stationName || '').trim();
|
|
if (Object.prototype.hasOwnProperty.call(stations, cleanName)) return stations[cleanName];
|
|
|
|
// Try matching by code and platform if no direct name match
|
|
const targetCode = getStationCodeFromName(cleanName); // Already lowercase and normalized
|
|
const targetPlatform = getPlatformNumberFromName(cleanName);
|
|
|
|
if (targetCode) {
|
|
for (const k in stations) {
|
|
if (!Object.prototype.hasOwnProperty.call(stations, k)) continue;
|
|
const st = stations[k];
|
|
// Normalize st.code similarly to how targetCode was generated
|
|
const stCodeRaw = st.code || getStationCodeFromName(st.name) || '';
|
|
const stCode = stCodeRaw.replace(/_/g, '-').toLowerCase();
|
|
|
|
const stPlatform = getPlatformNumberFromName(st.name);
|
|
if (stCode === targetCode && stPlatform === targetPlatform) {
|
|
return st;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getStationWs(stationName, fallbackWs) {
|
|
const st = getStationData(stationName);
|
|
if (st && st.ws && st.ws.readyState === WebSocket.OPEN) return st.ws;
|
|
if (fallbackWs && fallbackWs.readyState === WebSocket.OPEN) return fallbackWs;
|
|
return null;
|
|
}
|
|
|
|
function getStationGroupKey(station) {
|
|
if (!station) return null;
|
|
const code = normalizeStationCode(station.code || getStationCodeFromName(station.name));
|
|
const platform = normalizeStationPlatform(station.platform || getPlatformNumberFromName(station.name));
|
|
if (code) return `${code}@@${platform}`;
|
|
return `name@@${String(station.name || '').trim()}`;
|
|
}
|
|
|
|
function getStationGroupTarget(station) {
|
|
const groupKey = getStationGroupKey(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 code = normalizeStationCode(st.code || getStationCodeFromName(st.name)) || '';
|
|
const platform = normalizeStationPlatform(st.platform || getPlatformNumberFromName(st.name));
|
|
const key = getStationGroupKey(st);
|
|
if (!key) continue;
|
|
|
|
if (!groups.has(key)) {
|
|
groups.set(key, {
|
|
key,
|
|
target: getStationGroupTarget(st),
|
|
name: st.name,
|
|
code,
|
|
platform,
|
|
lastSeen: st.lastSeen || 0,
|
|
source: 'cc',
|
|
tags: ['CC'],
|
|
memberNames: [st.name],
|
|
memberCount: 1,
|
|
primaryName: st.name
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const group = groups.get(key);
|
|
group.memberNames.push(st.name);
|
|
group.memberCount = group.memberNames.length;
|
|
if ((st.lastSeen || 0) > (group.lastSeen || 0)) {
|
|
group.lastSeen = st.lastSeen || 0;
|
|
}
|
|
}
|
|
|
|
return Array.from(groups.values()).map(group => ({
|
|
...group,
|
|
tags: group.memberCount > 1 ? ['CC', `GROUP:${group.memberCount}`] : ['CC']
|
|
}));
|
|
}
|
|
|
|
function resolveStationGroup(selector) {
|
|
const clean = String(selector || '').trim();
|
|
if (!clean) return null;
|
|
|
|
if (Object.prototype.hasOwnProperty.call(stations, clean)) {
|
|
const st = stations[clean];
|
|
return {
|
|
target: getStationGroupTarget(st) || clean,
|
|
name: st.name,
|
|
code: normalizeStationCode(st.code || getStationCodeFromName(st.name)) || '',
|
|
platform: normalizeStationPlatform(st.platform || getPlatformNumberFromName(st.name)),
|
|
memberNames: [st.name],
|
|
memberCount: 1,
|
|
primaryName: st.name
|
|
};
|
|
}
|
|
|
|
for (const group of getStationGroups()) {
|
|
if (group.target === clean || group.name === 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 ws = getStationWs(stationName, fallbackWs);
|
|
if (!ws) return false;
|
|
try {
|
|
ws.send(JSON.stringify(msg));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function sendAudioBufferToStation(stationName, fallbackWs, task) {
|
|
const targetWs = getStationWs(stationName, fallbackWs);
|
|
if (!targetWs) throw new Error(`Station "${stationName}" not connected`);
|
|
|
|
const audioId = String(task.audioId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
const buf = task.buffer || null;
|
|
const durationSec = Number(task.durationSec || 0);
|
|
const audioFormat = String(task.audioFormat || 'dfpwm').toLowerCase();
|
|
const streamUrl = task.streamUrl ? String(task.streamUrl) : null;
|
|
|
|
const startMsg = {
|
|
type: 'audio_start',
|
|
stationName,
|
|
audioId,
|
|
byteLength: buf ? buf.length : 0,
|
|
sampleRate: Number(task.sampleRate || 48000),
|
|
channels: Number(task.channels || 1),
|
|
audioFormat,
|
|
streamUrl,
|
|
loop: !!task.loop,
|
|
priority: Number(task.priority || 0),
|
|
interruptMode: task.interruptMode || getInterruptModeForPriority(Number(task.priority || 0)),
|
|
source: task.source || 'unknown'
|
|
};
|
|
|
|
targetWs.send(JSON.stringify(startMsg));
|
|
|
|
if (streamUrl) {
|
|
return { audioId, cancelled: false, durationSec };
|
|
}
|
|
|
|
const token = task.cancelToken;
|
|
const SINGLE_SEND_LIMIT = 32 * 1024;
|
|
const CHUNK_SIZE = 16 * 1024;
|
|
|
|
if (buf.length <= SINGLE_SEND_LIMIT) {
|
|
if (token && token.cancelled) return { audioId, cancelled: true, durationSec };
|
|
targetWs.send(buf);
|
|
} else {
|
|
for (let offset = 0; offset < buf.length; offset += CHUNK_SIZE) {
|
|
if (token && token.cancelled) return { audioId, cancelled: true, durationSec };
|
|
const end = Math.min(offset + CHUNK_SIZE, buf.length);
|
|
targetWs.send(buf.slice(offset, end));
|
|
// For streaming, we can send slightly faster initially then steady
|
|
const isInitial = offset < CHUNK_SIZE * 4;
|
|
await delay(isInitial ? 20 : 60);
|
|
}
|
|
}
|
|
|
|
if (token && token.cancelled) return { audioId, cancelled: true, durationSec };
|
|
|
|
targetWs.send(JSON.stringify({
|
|
type: 'audio_end',
|
|
stationName,
|
|
audioId,
|
|
durationSec
|
|
}));
|
|
|
|
return { audioId, cancelled: false, durationSec };
|
|
}
|
|
|
|
function buildWavChunkFromPcm(buffer, sampleRate = 48000, channels = 1, bitsPerSample = 16) {
|
|
const dataSize = buffer.length;
|
|
const header = Buffer.alloc(44);
|
|
const byteRate = sampleRate * channels * (bitsPerSample / 8);
|
|
const blockAlign = channels * (bitsPerSample / 8);
|
|
|
|
header.write('RIFF', 0, 4, 'ascii');
|
|
header.writeUInt32LE(36 + dataSize, 4);
|
|
header.write('WAVE', 8, 4, 'ascii');
|
|
header.write('fmt ', 12, 4, 'ascii');
|
|
header.writeUInt32LE(16, 16);
|
|
header.writeUInt16LE(1, 20);
|
|
header.writeUInt16LE(channels, 22);
|
|
header.writeUInt32LE(sampleRate, 24);
|
|
header.writeUInt32LE(byteRate, 28);
|
|
header.writeUInt16LE(blockAlign, 32);
|
|
header.writeUInt16LE(bitsPerSample, 34);
|
|
header.write('data', 36, 4, 'ascii');
|
|
header.writeUInt32LE(dataSize, 40);
|
|
|
|
return Buffer.concat([header, buffer]);
|
|
}
|
|
|
|
async function sendLiveWavChunksToStation(stationName, fallbackWs, task) {
|
|
const targetWs = getStationWs(stationName, fallbackWs);
|
|
if (!targetWs) throw new Error(`Station "${stationName}" not connected`);
|
|
|
|
const sourcePath = String(task.sourcePath || '').trim();
|
|
if (!sourcePath) throw new Error('Live WAV sourcePath required');
|
|
if (!fs.existsSync(sourcePath)) throw new Error(`Live WAV source missing: ${sourcePath}`);
|
|
|
|
const audioId = String(task.audioId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
const durationSec = Number(task.durationSec || 0);
|
|
const loop = !!task.loop;
|
|
const token = task.cancelToken;
|
|
const sampleRate = 48000;
|
|
const channels = 1;
|
|
const bitsPerSample = 16;
|
|
const chunkDurationSec = 0.25;
|
|
const chunkBytes = Math.max(4096, Math.floor(sampleRate * channels * (bitsPerSample / 8) * chunkDurationSec));
|
|
|
|
targetWs.send(JSON.stringify({
|
|
type: 'audio_start',
|
|
stationName,
|
|
audioId,
|
|
byteLength: 0,
|
|
sampleRate,
|
|
channels,
|
|
audioFormat: 'wav-stream',
|
|
loop,
|
|
priority: Number(task.priority || 0),
|
|
interruptMode: task.interruptMode || getInterruptModeForPriority(Number(task.priority || 0)),
|
|
source: task.source || 'unknown'
|
|
}));
|
|
|
|
const ffmpegArgs = ['-hide_banner', '-loglevel', 'error'];
|
|
if (loop) ffmpegArgs.push('-stream_loop', '-1');
|
|
ffmpegArgs.push('-re', '-i', sourcePath, '-f', 's16le', '-acodec', 'pcm_s16le', '-ac', String(channels), '-ar', String(sampleRate), 'pipe:1');
|
|
log('INFO', `Live WAV chunk stream start for ${stationName}: ${path.basename(sourcePath)}${loop ? ' (loop)' : ''} chunk=${chunkDurationSec}s`);
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
let stderr = '';
|
|
let settled = false;
|
|
let proc = null;
|
|
let pcmPending = Buffer.alloc(0);
|
|
|
|
const finish = (result, error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (task.onCancel === cancelStream) task.onCancel = null;
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
try {
|
|
const ws = getStationWs(stationName, fallbackWs);
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({
|
|
type: 'audio_end',
|
|
stationName,
|
|
audioId,
|
|
durationSec: result.durationSec
|
|
}));
|
|
}
|
|
} catch {}
|
|
resolve(result);
|
|
};
|
|
|
|
const cancelStream = () => {
|
|
if (token) token.cancelled = true;
|
|
if (proc && !proc.killed) {
|
|
try { proc.kill('SIGKILL'); } catch {}
|
|
}
|
|
};
|
|
|
|
task.onCancel = cancelStream;
|
|
|
|
try {
|
|
proc = spawn('ffmpeg', ffmpegArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
} catch (err) {
|
|
finish(null, err);
|
|
return;
|
|
}
|
|
|
|
const flushChunk = (pcmChunk) => {
|
|
if (!pcmChunk || !pcmChunk.length) return;
|
|
const ws = getStationWs(stationName, fallbackWs);
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
cancelStream();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
ws.send(buildWavChunkFromPcm(pcmChunk, sampleRate, channels, bitsPerSample));
|
|
} catch (err) {
|
|
finish(null, err);
|
|
}
|
|
};
|
|
|
|
proc.stdout.on('data', (chunk) => {
|
|
if (!chunk || !chunk.length || settled) return;
|
|
if (token && token.cancelled) {
|
|
cancelStream();
|
|
return;
|
|
}
|
|
|
|
pcmPending = pcmPending.length > 0 ? Buffer.concat([pcmPending, chunk]) : Buffer.from(chunk);
|
|
while (pcmPending.length >= chunkBytes && !settled) {
|
|
const part = pcmPending.subarray(0, chunkBytes);
|
|
pcmPending = pcmPending.subarray(chunkBytes);
|
|
flushChunk(part);
|
|
}
|
|
});
|
|
|
|
proc.stderr.on('data', (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
|
|
proc.on('error', (err) => {
|
|
finish(null, err);
|
|
});
|
|
|
|
proc.on('close', (code, signal) => {
|
|
const cancelled = token && token.cancelled;
|
|
if (!cancelled && pcmPending.length > 0) {
|
|
flushChunk(pcmPending);
|
|
pcmPending = Buffer.alloc(0);
|
|
}
|
|
log(cancelled ? 'INFO' : 'SUCCESS', `Live WAV chunk stream end for ${stationName}: ${path.basename(sourcePath)}${cancelled ? ' (cancelled)' : ''}`);
|
|
if (!cancelled && code !== 0) {
|
|
finish(null, new Error((stderr || `ffmpeg exited with code ${code ?? 'unknown'} signal ${signal ?? 'none'}`).trim()));
|
|
return;
|
|
}
|
|
finish({ audioId, cancelled: !!cancelled, durationSec: loop ? 0 : durationSec });
|
|
});
|
|
});
|
|
}
|
|
|
|
const stationMusicStates = new Map();
|
|
|
|
function 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 getPlaybackAudioByBaseName(baseName) {
|
|
const base = normalizeBaseName(baseName);
|
|
if (!base) throw new Error('Invalid track name');
|
|
|
|
for (const ext of STREAMABLE_AUDIO_EXTENSIONS) {
|
|
const candidates = [
|
|
path.join(audioMusicDir, `${base}${ext}`),
|
|
...getBeepAudioDirs().map(dir => path.join(dir, `${base}${ext}`)),
|
|
path.join(audioStationDir, `${base}${ext}`)
|
|
];
|
|
for (const p of candidates) {
|
|
if (!fs.existsSync(p)) continue;
|
|
let durationSec = 0;
|
|
if (ext === '.wav') {
|
|
const buffer = fs.readFileSync(p);
|
|
durationSec = getWavDurationSec(buffer) || 0;
|
|
}
|
|
return {
|
|
sourcePath: p,
|
|
audioFormat: 'stream',
|
|
streamUrl: getManagedMediaUrl(
|
|
p.startsWith(audioStationDir) ? 'audio/station' :
|
|
(p.startsWith(audioMusicDir) ? 'audio/music' : 'audio/beep'),
|
|
path.basename(p)
|
|
),
|
|
durationSec
|
|
};
|
|
}
|
|
}
|
|
|
|
for (const p of [
|
|
path.join(audioMusicDir, `${base}.dfpwm`),
|
|
...getBeepAudioDirs().map(dir => path.join(dir, `${base}.dfpwm`)),
|
|
path.join(audioStationDir, `${base}.dfpwm`)
|
|
]) {
|
|
if (!fs.existsSync(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' || newP >= 2) {
|
|
cancelStationAudioTask(cur);
|
|
q.queue.length = 0;
|
|
sendAudioControl(cleanStation, { type: 'audio_stop', mode: 'all', reason: 'emergency' }, fallbackWs);
|
|
} else if (cur) {
|
|
const curP = Number(cur.priority || 0);
|
|
if (newP > curP) {
|
|
cancelStationAudioTask(cur);
|
|
sendAudioControl(cleanStation, { type: 'audio_stop', mode: 'current', flushBelow: newP, reason: 'preempt' }, fallbackWs);
|
|
if (cur.requeueOnPreempt) {
|
|
const replay = { ...cur, cancelToken: null };
|
|
replay.audioId = `${String(replay.audioId || '')}-replay-${Date.now()}`;
|
|
insertTaskByPriority(q.queue, replay);
|
|
}
|
|
}
|
|
}
|
|
|
|
insertTaskByPriority(q.queue, task);
|
|
processStationAudioQueue(cleanStation, fallbackWs).catch(() => {});
|
|
}
|
|
|
|
async function processStationAudioQueue(stationName, fallbackWs) {
|
|
const cleanStation = String(stationName || '').trim();
|
|
const q = getStationAudioQueue(cleanStation);
|
|
if (q.processing) return;
|
|
q.processing = true;
|
|
try {
|
|
while (q.queue.length > 0) {
|
|
const task = q.queue.shift();
|
|
task.cancelToken = { cancelled: false };
|
|
q.current = task;
|
|
|
|
try {
|
|
if (task.consoleStreamStart) broadcastToConsole(task.consoleStreamStart);
|
|
const result = task.audioFormat === 'wav-stream-live'
|
|
? await sendLiveWavChunksToStation(cleanStation, fallbackWs, task)
|
|
: await sendAudioBufferToStation(cleanStation, fallbackWs, task);
|
|
if (task.consoleStreamEnd) {
|
|
if (result.cancelled) {
|
|
broadcastToConsole(task.consoleStreamEnd);
|
|
} else {
|
|
task.endTimer = setTimeout(() => {
|
|
try { broadcastToConsole(task.consoleStreamEnd); } catch {}
|
|
}, Math.max(0, Number(result.durationSec || 0) * 1000));
|
|
}
|
|
}
|
|
if (task.onDone) task.onDone({ ...result, stationName: cleanStation });
|
|
} catch (e) {
|
|
if (task.consoleStreamEnd) broadcastToConsole(task.consoleStreamEnd);
|
|
if (task.onDone) task.onDone({ cancelled: true, error: e?.message || String(e), stationName: cleanStation });
|
|
} finally {
|
|
q.current = null;
|
|
}
|
|
}
|
|
} finally {
|
|
q.processing = false;
|
|
}
|
|
}
|
|
|
|
function loadData() {
|
|
if (fs.existsSync(DATA_FILE)) {
|
|
try {
|
|
const data = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
|
|
if (data.stations) stations = data.stations;
|
|
if (data.activeTrains) activeTrains = data.activeTrains;
|
|
if (data.audioVersion) audioVersion = String(data.audioVersion);
|
|
if (Array.isArray(data.versions)) versions = data.versions;
|
|
refreshStationIndex();
|
|
log('INFO', 'Data loaded from disk');
|
|
} catch (e) {
|
|
log('ERROR', `Failed to load data: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function saveData() {
|
|
try {
|
|
// Create a copy of stations without ws objects
|
|
const stationsToSave = {};
|
|
for (const key in stations) {
|
|
const { ws, ...rest } = stations[key];
|
|
stationsToSave[key] = rest;
|
|
}
|
|
|
|
const data = {
|
|
stations: stationsToSave,
|
|
activeTrains,
|
|
audioVersion,
|
|
versions
|
|
};
|
|
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
|
|
} catch (e) {
|
|
log('ERROR', `Failed to save data: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
function normalizeVersionString(input) {
|
|
if (input === undefined || input === null) return '';
|
|
if (typeof input !== 'string') input = String(input);
|
|
input = input.trim();
|
|
if (input.length > 64) input = input.slice(0, 64);
|
|
return input;
|
|
}
|
|
|
|
function resolveManagedPath(inputPath) {
|
|
const normalized = String(inputPath || '').replace(/\\/g, '/').replace(/^\/+/, '').trim();
|
|
if (!normalized) return null;
|
|
|
|
if (normalized === 'list.txt' || normalized === 'uploads/list.txt') {
|
|
return { logicalPath: 'list.txt', fullPath: listFilePath };
|
|
}
|
|
|
|
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/station/', baseDir: audioStationDir, logicalPrefix: 'Audio/station/' },
|
|
{ prefix: 'uploads/audio/station/', baseDir: audioStationDir, logicalPrefix: 'Audio/station/' }
|
|
];
|
|
|
|
for (const entry of prefixes) {
|
|
if (!normalized.startsWith(entry.prefix)) continue;
|
|
const relativeName = normalized.slice(entry.prefix.length).trim();
|
|
if (!relativeName || relativeName.includes('/') || relativeName.includes('\\')) return null;
|
|
return {
|
|
logicalPath: `${entry.logicalPrefix}${relativeName}`,
|
|
fullPath: path.join(entry.baseDir, relativeName)
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getManagedFiles() {
|
|
const files = [];
|
|
|
|
if (fs.existsSync(listFilePath)) {
|
|
const stats = fs.statSync(listFilePath);
|
|
files.push({
|
|
name: 'list.txt',
|
|
path: 'list.txt',
|
|
type: 'config',
|
|
size: stats.size,
|
|
mtime: stats.mtimeMs
|
|
});
|
|
}
|
|
|
|
if (fs.existsSync(audioMusicDir)) {
|
|
fs.readdirSync(audioMusicDir).forEach(file => {
|
|
if (file.endsWith('.dfpwm')) {
|
|
const wavName = file.replace(/\.dfpwm$/, '.wav');
|
|
if (fs.existsSync(path.join(audioMusicDir, wavName))) return;
|
|
}
|
|
const filePath = path.join(audioMusicDir, file);
|
|
const stats = fs.statSync(filePath);
|
|
files.push({
|
|
name: file,
|
|
path: `Audio/music/${file}`,
|
|
type: 'music',
|
|
size: stats.size,
|
|
mtime: stats.mtimeMs
|
|
});
|
|
});
|
|
}
|
|
|
|
if (fs.existsSync(audioVoiceDir)) {
|
|
fs.readdirSync(audioVoiceDir).forEach(file => {
|
|
if (file.endsWith('.dfpwm')) {
|
|
const wavName = file.replace(/\.dfpwm$/, '.wav');
|
|
if (fs.existsSync(path.join(audioVoiceDir, wavName))) return;
|
|
}
|
|
const filePath = path.join(audioVoiceDir, file);
|
|
const stats = fs.statSync(filePath);
|
|
files.push({
|
|
name: file,
|
|
path: `Audio/voice/${file}`,
|
|
type: 'voice',
|
|
size: stats.size,
|
|
mtime: stats.mtimeMs
|
|
});
|
|
});
|
|
}
|
|
|
|
const beepSeen = new Set();
|
|
for (const dir of getBeepAudioDirs()) {
|
|
if (!fs.existsSync(dir)) continue;
|
|
fs.readdirSync(dir).forEach(file => {
|
|
if (beepSeen.has(file)) return;
|
|
beepSeen.add(file);
|
|
if (file.endsWith('.dfpwm')) {
|
|
const wavName = file.replace(/\.dfpwm$/, '.wav');
|
|
if (beepSeen.has(wavName) || fs.existsSync(path.join(dir, wavName))) return;
|
|
}
|
|
const filePath = path.join(dir, file);
|
|
const stats = fs.statSync(filePath);
|
|
files.push({
|
|
name: file,
|
|
path: `Audio/beep/${file}`,
|
|
type: file === 'line.txt' ? 'config' : 'beep',
|
|
size: stats.size,
|
|
mtime: stats.mtimeMs
|
|
});
|
|
});
|
|
}
|
|
|
|
if (fs.existsSync(audioStationDir)) {
|
|
fs.readdirSync(audioStationDir).forEach(file => {
|
|
if (file.endsWith('.dfpwm')) {
|
|
const wavName = file.replace(/\.dfpwm$/, '.wav');
|
|
if (fs.existsSync(path.join(audioStationDir, wavName))) return;
|
|
}
|
|
const filePath = path.join(audioStationDir, file);
|
|
const stats = fs.statSync(filePath);
|
|
files.push({
|
|
name: file,
|
|
path: `Audio/station/${file}`,
|
|
type: 'station',
|
|
size: stats.size,
|
|
mtime: stats.mtimeMs
|
|
});
|
|
});
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
function snapshotFiles() {
|
|
const snapshot = {};
|
|
const files = getManagedFiles();
|
|
for (const f of files) {
|
|
snapshot[f.path] = { mtime: f.mtime, size: f.size, type: f.type, name: f.name };
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
function diffSnapshots(prevSnap, nextSnap) {
|
|
const prev = prevSnap || {};
|
|
const next = nextSnap || {};
|
|
const added = [];
|
|
const modified = [];
|
|
const deleted = [];
|
|
|
|
for (const p of Object.keys(next)) {
|
|
if (!prev[p]) added.push(p);
|
|
else if (prev[p].mtime !== next[p].mtime || prev[p].size !== next[p].size) modified.push(p);
|
|
}
|
|
for (const p of Object.keys(prev)) {
|
|
if (!next[p]) deleted.push(p);
|
|
}
|
|
|
|
added.sort();
|
|
modified.sort();
|
|
deleted.sort();
|
|
|
|
return { added, modified, deleted };
|
|
}
|
|
|
|
function log(type, message) {
|
|
const timestamp = new Date().toISOString();
|
|
const logEntry = `[${timestamp}] [${type}] ${message}`;
|
|
console.log(logEntry);
|
|
stationLogs.unshift(logEntry);
|
|
if (stationLogs.length > 100) stationLogs.pop();
|
|
|
|
// Broadcast logs to web console
|
|
broadcastToConsole({ type: 'log', data: logEntry });
|
|
}
|
|
|
|
const trainEventCooldown = new Map(); // trainId|stationName|type -> timestamp
|
|
function normalizeName(value) {
|
|
return String(value || '').trim();
|
|
}
|
|
|
|
function getStationCodeFromName(name) {
|
|
if (!name) return null;
|
|
|
|
// 1. Try to find ID in parentheses or brackets (e.g. "Station (01-01/01-02)")
|
|
const parenMatch = name.match(/[\(\[]([a-zA-Z0-9\-\/]+)[\)\]]/);
|
|
if (parenMatch) return parenMatch[1].split('/')[0].replace('_', '-').trim().toLowerCase();
|
|
|
|
// 2. Try extraction from anywhere in string (e.g. "01-01" or "01_01")
|
|
const m = name.match(/([a-zA-Z0-9]+[-_][a-zA-Z0-9]+)/);
|
|
if (m) return m[1].replace('_', '-').toLowerCase();
|
|
|
|
// 3. Fallback to first part of name if it looks like a code or a name
|
|
const parts = name.split(/[\s?]+/);
|
|
const firstPart = parts[0];
|
|
|
|
// Ignore common generic parts
|
|
if (firstPart === 'Station' || firstPart === 'Platform' || firstPart === 'Track') {
|
|
if (parts[1]) return parts[1].toLowerCase();
|
|
}
|
|
|
|
if (firstPart && firstPart.length > 0) {
|
|
const code = firstPart.replace('_', '-').toLowerCase();
|
|
// log('DEBUG', `Extracted code "${code}" from "${name}"`);
|
|
return code;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getPlatformNumberFromName(name) {
|
|
if (!name) return "1";
|
|
// 1. Try "Platform X" or "Track X" (case insensitive)
|
|
const m1 = name.match(/(?:platform|track)\s*([0-9]+[a-zA-Z]?)/i);
|
|
if (m1) return m1[1];
|
|
|
|
// 2. Fallback to digits at the end
|
|
const m2 = name.match(/([0-9]+[a-zA-Z]?)$/);
|
|
return m2 ? m2[1] : "1";
|
|
}
|
|
|
|
function normalizeStationCode(code) {
|
|
const value = String(code || '').trim().replace(/_/g, '-').toLowerCase();
|
|
return value || null;
|
|
}
|
|
|
|
function normalizeStationPlatform(platform) {
|
|
const value = String(platform || '').trim().toLowerCase();
|
|
return value || '1';
|
|
}
|
|
|
|
function normalizeStationRecord(station) {
|
|
if (!station || !station.name) return;
|
|
station.platform = normalizeStationPlatform(station.platform || getPlatformNumberFromName(station.name));
|
|
station.code = normalizeStationCode(station.code || getStationCodeFromName(station.name)) || station.code || '';
|
|
}
|
|
|
|
function refreshStationIndex() {
|
|
for (const station of Object.values(stations)) {
|
|
normalizeStationRecord(station);
|
|
}
|
|
}
|
|
|
|
function getBaseTrainId(trainId) {
|
|
if (!trainId) return "unknown";
|
|
|
|
// Ignore split off trains which are temporary and cause false announcements
|
|
if (trainId.includes("Split off from")) return null;
|
|
|
|
// Remove car-specific info like "01 3501F" from "CK 1 01 3501F"
|
|
// Also remove brackets like "[Local] Train 1" -> "Train 1"
|
|
let clean = trainId.replace(/^\[.*?\]\s*/, '');
|
|
|
|
// Look for patterns like "CK 1 01" and keep only "CK 1"
|
|
const parts = clean.split(/\s+/);
|
|
if (parts.length >= 3) {
|
|
// If it looks like "NAME NUM CAR ...", keep "NAME NUM"
|
|
if (parts[0].length <= 8 && !isNaN(parts[1])) {
|
|
return `${parts[0]} ${parts[1]}`;
|
|
}
|
|
}
|
|
|
|
// Handle dash patterns like "123-01" -> "123"
|
|
const dashMatch = parts[0].match(/^([\w\d]+)\-[\w\d]+$/);
|
|
if (dashMatch) return dashMatch[1];
|
|
|
|
return parts[0] + (parts[1] ? ' ' + parts[1] : '');
|
|
}
|
|
|
|
// Radio Listeners
|
|
const radioListeners = {};
|
|
const hlsStreams = {};
|
|
|
|
function sanitizeStreamName(name) {
|
|
return String(name || '')
|
|
.trim()
|
|
.replace(/\s+/g, '_')
|
|
.replace(/[^a-zA-Z0-9_\-]/g, '_')
|
|
.replace(/_+/g, '_')
|
|
.replace(/^_+|_+$/g, '')
|
|
.slice(0, 80) || 'UNK';
|
|
}
|
|
|
|
function getPrimaryStationCode(code) {
|
|
if (!code) return null;
|
|
const first = String(code).split(/[,\s|/]+/).map(s => s.trim()).filter(Boolean)[0];
|
|
return first || null;
|
|
}
|
|
|
|
function getStationStreamName(stationName) {
|
|
const st = getStationData(stationName);
|
|
const primaryCode = getPrimaryStationCode(st?.code);
|
|
return sanitizeStreamName(primaryCode || stationName);
|
|
}
|
|
|
|
function getStationHlsUrl(streamName) {
|
|
const base = String(STABROADCAST_PUBLIC_BASE || '').replace(/\/+$/g, '');
|
|
return `${base}/${encodeURIComponent(streamName)}${STABROADCAST_HLS_SUFFIX}`;
|
|
}
|
|
|
|
function guessSrsHttpBase(req) {
|
|
const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim();
|
|
const hostRaw = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim();
|
|
if (!hostRaw) return null;
|
|
|
|
const m = hostRaw.match(/^(.+?)(?::(\d+))?$/);
|
|
const host = m?.[1] || hostRaw;
|
|
const port = m?.[2];
|
|
|
|
const withPort = port ? `${host}:8080` : `${host}:8080`;
|
|
return `${proto}://${withPort}/live`;
|
|
}
|
|
|
|
function getStationHlsUrlForRequest(streamName, req) {
|
|
const base = String(process.env.STABROADCAST_PUBLIC_BASE || guessSrsHttpBase(req) || STABROADCAST_PUBLIC_BASE_DEFAULT).replace(/\/+$/g, '');
|
|
return `${base}/${encodeURIComponent(streamName)}${STABROADCAST_HLS_SUFFIX}`;
|
|
}
|
|
|
|
function getLocalHlsUrlForRequest(streamName, req) {
|
|
const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim();
|
|
const hostRaw = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim();
|
|
const base = hostRaw ? `${proto}://${hostRaw}` : '';
|
|
return `${base}/hls/${encodeURIComponent(streamName)}/index.m3u8`;
|
|
}
|
|
|
|
function ensureSilenceWav() {
|
|
if (fs.existsSync(silence1sWavPath)) return;
|
|
execSync(`ffmpeg -y -v quiet -f lavfi -i anullsrc=r=48000:cl=mono -t 1 -c:a pcm_s16le "${silence1sWavPath}"`);
|
|
}
|
|
|
|
function getOrCreateHlsStreamState(streamName) {
|
|
if (!hlsStreams[streamName]) {
|
|
hlsStreams[streamName] = {
|
|
streamName,
|
|
stationName: null,
|
|
stationCode: null,
|
|
hlsUrl: getStationHlsUrl(streamName),
|
|
status: 'idle',
|
|
lastStartAt: null,
|
|
lastEndAt: null,
|
|
lastSeq: null,
|
|
lastError: null,
|
|
lastExitCode: null,
|
|
queue: Promise.resolve(),
|
|
currentProcess: null
|
|
};
|
|
}
|
|
return hlsStreams[streamName];
|
|
}
|
|
|
|
function buildConcatListFile(seq) {
|
|
ensureSilenceWav();
|
|
const files = [];
|
|
|
|
for (const item of seq) {
|
|
if (item === '__DELAY_1__') {
|
|
files.push(silence1sWavPath);
|
|
continue;
|
|
}
|
|
|
|
const candidates = [
|
|
path.join(audioStationDir, `${item}.wav`),
|
|
...getBeepAudioDirs().map(dir => path.join(dir, `${item}.wav`))
|
|
];
|
|
|
|
const found = candidates.find(p => fs.existsSync(p));
|
|
if (found) {
|
|
files.push(found);
|
|
} else {
|
|
log('WARN', `HLS audio missing on server (wav required): ${item}`);
|
|
}
|
|
}
|
|
|
|
if (HLS_TAIL_SILENCE_SEC > 0) {
|
|
for (let i = 0; i < HLS_TAIL_SILENCE_SEC; i++) files.push(silence1sWavPath);
|
|
}
|
|
|
|
if (files.length === 0) return null;
|
|
|
|
const listPath = path.join(tmpDir, `concat-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`);
|
|
const content = files
|
|
.map(p => `file '${p.replace(/\\/g, '/').replace(/'/g, "\\'")}'`)
|
|
.join('\n');
|
|
fs.writeFileSync(listPath, content, 'utf8');
|
|
return listPath;
|
|
}
|
|
|
|
function buildSequenceWavFile(seq) {
|
|
const listPath = buildConcatListFile(seq);
|
|
if (!listPath) return null;
|
|
|
|
const fileName = `sequence-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`;
|
|
const outWavPath = path.join(tempBroadcastDir, fileName);
|
|
try {
|
|
execSync(`ffmpeg -hide_banner -loglevel error -y -f concat -safe 0 -i "${listPath}" -ac 1 -ar 48000 -c:a pcm_s16le "${outWavPath}"`);
|
|
const buffer = fs.readFileSync(outWavPath);
|
|
const durationSec = getWavDurationSec(buffer) || 0;
|
|
setTimeout(() => {
|
|
try { fs.unlinkSync(outWavPath); } catch {}
|
|
}, Math.max(10 * 60 * 1000, Math.ceil(durationSec * 2000)));
|
|
return {
|
|
audioFormat: 'stream',
|
|
streamUrl: getManagedMediaUrl('temp', fileName),
|
|
durationSec
|
|
};
|
|
} finally {
|
|
try { fs.unlinkSync(listPath); } catch {}
|
|
}
|
|
}
|
|
|
|
function runFfmpegRtmpPush(streamName, seq) {
|
|
const state = getOrCreateHlsStreamState(streamName);
|
|
const listPath = buildConcatListFile(seq);
|
|
if (!listPath) {
|
|
state.status = 'idle';
|
|
state.lastError = 'No playable wav files in sequence';
|
|
return Promise.resolve();
|
|
}
|
|
|
|
const rtmpUrl = `${SRS_RTMP_BASE.replace(/\/+$/g, '')}/${encodeURIComponent(streamName)}`;
|
|
|
|
state.lastSeq = seq;
|
|
state.lastError = null;
|
|
state.lastExitCode = null;
|
|
|
|
if (HLS_PREBUILD) {
|
|
const tmpAacPath = path.join(tmpDir, `prebuild-${Date.now()}-${Math.random().toString(16).slice(2)}.aac`);
|
|
const buildArgs = [
|
|
'-hide_banner',
|
|
'-loglevel',
|
|
'error',
|
|
'-y',
|
|
'-f',
|
|
'concat',
|
|
'-safe',
|
|
'0',
|
|
'-i',
|
|
listPath,
|
|
'-ac',
|
|
'1',
|
|
'-ar',
|
|
'48000',
|
|
'-c:a',
|
|
'aac',
|
|
'-b:a',
|
|
'128k',
|
|
'-f',
|
|
'adts',
|
|
tmpAacPath
|
|
];
|
|
|
|
state.status = 'building';
|
|
state.lastStartAt = Date.now();
|
|
|
|
const buildProc = spawn('ffmpeg', buildArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
let buildStderr = '';
|
|
buildProc.stderr.on('data', (buf) => {
|
|
buildStderr += buf.toString('utf8');
|
|
if (buildStderr.length > 8000) buildStderr = buildStderr.slice(-8000);
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
buildProc.on('close', (buildCode) => {
|
|
if (buildCode !== 0) {
|
|
state.lastExitCode = buildCode;
|
|
state.status = 'idle';
|
|
state.lastEndAt = Date.now();
|
|
state.lastError = (buildStderr || `ffmpeg exited with code ${buildCode}`).trim();
|
|
log('ERROR', `HLS build failed for ${streamName}: ${state.lastError}`);
|
|
try { fs.unlinkSync(listPath); } catch {}
|
|
try { fs.unlinkSync(tmpAacPath); } catch {}
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const pushArgs = [
|
|
'-hide_banner',
|
|
'-loglevel',
|
|
'error',
|
|
'-re',
|
|
'-i',
|
|
tmpAacPath,
|
|
'-c:a',
|
|
'copy',
|
|
'-f',
|
|
'flv',
|
|
rtmpUrl
|
|
];
|
|
|
|
state.status = 'pushing';
|
|
const proc = spawn('ffmpeg', pushArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
state.currentProcess = proc;
|
|
|
|
let stderr = '';
|
|
proc.stderr.on('data', (buf) => {
|
|
stderr += buf.toString('utf8');
|
|
if (stderr.length > 8000) stderr = stderr.slice(-8000);
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
state.lastExitCode = code;
|
|
state.status = 'idle';
|
|
state.lastEndAt = Date.now();
|
|
state.currentProcess = null;
|
|
if (code !== 0) {
|
|
state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim();
|
|
log('ERROR', `HLS push failed for ${streamName}: ${state.lastError}`);
|
|
}
|
|
try { fs.unlinkSync(listPath); } catch {}
|
|
try { fs.unlinkSync(tmpAacPath); } catch {}
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
const args = [
|
|
'-hide_banner',
|
|
'-loglevel',
|
|
'error',
|
|
'-re',
|
|
'-f',
|
|
'concat',
|
|
'-safe',
|
|
'0',
|
|
'-i',
|
|
listPath,
|
|
'-ac',
|
|
'1',
|
|
'-ar',
|
|
'48000',
|
|
'-c:a',
|
|
'aac',
|
|
'-b:a',
|
|
'128k',
|
|
'-f',
|
|
'flv',
|
|
rtmpUrl
|
|
];
|
|
|
|
state.status = 'pushing';
|
|
state.lastStartAt = Date.now();
|
|
|
|
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
state.currentProcess = proc;
|
|
|
|
let stderr = '';
|
|
proc.stderr.on('data', (buf) => {
|
|
stderr += buf.toString('utf8');
|
|
if (stderr.length > 8000) stderr = stderr.slice(-8000);
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
proc.on('close', (code) => {
|
|
state.lastExitCode = code;
|
|
state.status = 'idle';
|
|
state.lastEndAt = Date.now();
|
|
state.currentProcess = null;
|
|
if (code !== 0) {
|
|
state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim();
|
|
log('ERROR', `HLS push failed for ${streamName}: ${state.lastError}`);
|
|
}
|
|
try { fs.unlinkSync(listPath); } catch {}
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function clearLocalHlsStreamDir(streamName) {
|
|
const dir = path.join(localHlsDir, streamName);
|
|
if (!fs.existsSync(dir)) return dir;
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
for (const ent of entries) {
|
|
const p = path.join(dir, ent.name);
|
|
if (ent.isDirectory()) {
|
|
fs.rmSync(p, { recursive: true, force: true });
|
|
} else {
|
|
try { fs.unlinkSync(p); } catch {}
|
|
}
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
function runFfmpegLocalHlsBuild(streamName, seq) {
|
|
const state = getOrCreateHlsStreamState(streamName);
|
|
const listPath = buildConcatListFile(seq);
|
|
if (!listPath) {
|
|
state.status = 'idle';
|
|
state.lastError = 'No playable wav files in sequence';
|
|
return Promise.resolve();
|
|
}
|
|
|
|
state.lastSeq = seq;
|
|
state.lastError = null;
|
|
state.lastExitCode = null;
|
|
state.status = 'building';
|
|
state.lastStartAt = Date.now();
|
|
|
|
const outDir = clearLocalHlsStreamDir(streamName);
|
|
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
|
|
const playlistPath = path.join(outDir, 'index.m3u8');
|
|
const segPattern = path.join(outDir, 'seg-%05d.ts');
|
|
const args = [
|
|
'-hide_banner',
|
|
'-loglevel',
|
|
'error',
|
|
'-y',
|
|
'-f',
|
|
'concat',
|
|
'-safe',
|
|
'0',
|
|
'-i',
|
|
listPath,
|
|
'-ac',
|
|
'1',
|
|
'-ar',
|
|
'48000',
|
|
'-c:a',
|
|
'aac',
|
|
'-b:a',
|
|
'128k',
|
|
'-f',
|
|
'hls',
|
|
'-hls_time',
|
|
'2',
|
|
'-hls_list_size',
|
|
'0',
|
|
'-hls_playlist_type',
|
|
'vod',
|
|
'-hls_flags',
|
|
'independent_segments',
|
|
'-hls_segment_filename',
|
|
segPattern,
|
|
playlistPath
|
|
];
|
|
|
|
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
state.currentProcess = proc;
|
|
|
|
let stderr = '';
|
|
proc.stderr.on('data', (buf) => {
|
|
stderr += buf.toString('utf8');
|
|
if (stderr.length > 8000) stderr = stderr.slice(-8000);
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
proc.on('close', (code) => {
|
|
state.lastExitCode = code;
|
|
state.status = 'idle';
|
|
state.lastEndAt = Date.now();
|
|
state.currentProcess = null;
|
|
if (code !== 0) {
|
|
state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim();
|
|
log('ERROR', `Local HLS build failed for ${streamName}: ${state.lastError}`);
|
|
}
|
|
try { fs.unlinkSync(listPath); } catch {}
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
function enqueueHlsPush(stationName, seq) {
|
|
if (!HLS_ENABLE) return;
|
|
if (!Array.isArray(seq) || seq.length === 0) return;
|
|
|
|
const streamName = getStationStreamName(stationName);
|
|
const st = getStationData(stationName);
|
|
const state = getOrCreateHlsStreamState(streamName);
|
|
state.stationName = stationName;
|
|
state.stationCode = getPrimaryStationCode(st?.code);
|
|
state.hlsUrl = state.hlsUrl || getStationHlsUrl(streamName);
|
|
|
|
if (HLS_STREAM_MODE === 'replace' && state.currentProcess) {
|
|
try { state.currentProcess.kill('SIGKILL'); } catch {}
|
|
}
|
|
|
|
state.queue = state.queue
|
|
.then(() => (HLS_OUTPUT_MODE === 'local' ? runFfmpegLocalHlsBuild(streamName, seq) : runFfmpegRtmpPush(streamName, seq)))
|
|
.catch((e) => {
|
|
state.status = 'idle';
|
|
state.lastError = e?.message || String(e);
|
|
log('ERROR', `HLS push error for ${streamName}: ${state.lastError}`);
|
|
});
|
|
}
|
|
|
|
// WebSocket handling
|
|
wss.on('connection', (ws, req) => {
|
|
const ip = req.socket.remoteAddress;
|
|
|
|
// Check if it's a radio connection
|
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
if (url.pathname === '/api/radio') {
|
|
const station = url.searchParams.get('station');
|
|
if (station) {
|
|
const cleanStation = station.trim();
|
|
if (!radioListeners[cleanStation]) radioListeners[cleanStation] = [];
|
|
|
|
// To prevent zombie connections, close existing connections from the same IP
|
|
// Since CC might reconnect quickly and leave old ones hanging
|
|
radioListeners[cleanStation] = radioListeners[cleanStation].filter(existingWs => {
|
|
if (existingWs.readyState !== WebSocket.OPEN) return false;
|
|
// If it's the same IP connecting again, close the old one to save CC resources
|
|
if (existingWs._socket && req.socket && existingWs._socket.remoteAddress === req.socket.remoteAddress) {
|
|
existingWs.close();
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
radioListeners[cleanStation].push(ws);
|
|
log('INFO', `Radio listener connected for station: ${cleanStation}`);
|
|
|
|
// Send an empty initial frame to let the client know it's connected and ready
|
|
// DFPWM silence is 0xAA. 24000 bytes = 4 seconds of silence to pre-fill buffer.
|
|
ws.send(Buffer.alloc(24000, 0xAA));
|
|
|
|
ws.on('close', () => {
|
|
radioListeners[cleanStation] = radioListeners[cleanStation].filter(l => l !== ws);
|
|
log('INFO', `Radio listener disconnected for station: ${cleanStation}`);
|
|
});
|
|
|
|
// Ping to keep alive
|
|
const pingInterval = setInterval(() => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.ping();
|
|
} else {
|
|
clearInterval(pingInterval);
|
|
}
|
|
}, 30000);
|
|
|
|
ws.on('close', () => clearInterval(pingInterval));
|
|
return;
|
|
}
|
|
}
|
|
|
|
ws.on('message', (message) => {
|
|
try {
|
|
const data = JSON.parse(message);
|
|
handleMessage(ws, data);
|
|
} catch (e) {
|
|
log('ERROR', `Failed to parse message: ${e.message}`);
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
// Handle disconnection if necessary
|
|
});
|
|
});
|
|
|
|
function handleMessage(ws, data) {
|
|
// Check if it's a console connection
|
|
if (data.type === 'console_connect') {
|
|
ws.isConsole = true;
|
|
// Send initial state
|
|
ws.send(JSON.stringify({
|
|
type: 'init_state',
|
|
stations,
|
|
activeTrains,
|
|
logs: stationLogs
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// Handle Station Messages
|
|
switch (data.type) {
|
|
case 'station_register':
|
|
handleStationRegister(ws, data);
|
|
break;
|
|
case 'train_arrival':
|
|
handleTrainArrival(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':
|
|
// TODO: Send list.txt content
|
|
break;
|
|
case 'heartbeat':
|
|
handleHeartbeat(ws, data);
|
|
break;
|
|
default:
|
|
log('WARNING', `Unknown message type: ${data.type}`);
|
|
}
|
|
}
|
|
|
|
function getCorrectStationName(name) {
|
|
const cleanName = String(name || '').trim();
|
|
const station = findStationByFlexibleName(cleanName);
|
|
if (station && station.name) {
|
|
return station.name;
|
|
}
|
|
return cleanName;
|
|
}
|
|
|
|
function handleHeartbeat(ws, data) {
|
|
if (data.stationName) {
|
|
const cleanName = data.stationName.trim();
|
|
if (!stations[cleanName]) {
|
|
// Auto-register if missing (e.g. server restarted)
|
|
stations[cleanName] = {
|
|
name: cleanName,
|
|
code: data.stationCode || '',
|
|
platform: data.stationPlatform || getPlatformNumberFromName(cleanName),
|
|
version: data.stationVersion || '',
|
|
lastSeen: Date.now(),
|
|
ws: ws
|
|
};
|
|
normalizeStationRecord(stations[cleanName]);
|
|
log('INFO', `Station auto-registered via heartbeat: "${cleanName}"${data.stationVersion ? ` ${data.stationVersion}` : ''}`);
|
|
broadcastUpdate();
|
|
} else {
|
|
stations[cleanName].lastSeen = Date.now();
|
|
if (data.stationCode) stations[cleanName].code = data.stationCode;
|
|
if (data.stationPlatform) stations[cleanName].platform = data.stationPlatform;
|
|
if (data.stationVersion) stations[cleanName].version = data.stationVersion;
|
|
if (stations[cleanName].ws !== ws) {
|
|
stations[cleanName].ws = ws; // Update WS in case of reconnect
|
|
log('INFO', `Station WebSocket updated via heartbeat: "${cleanName}"${data.stationVersion ? ` ${data.stationVersion}` : ''}`);
|
|
broadcastUpdate();
|
|
}
|
|
normalizeStationRecord(stations[cleanName]);
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleStationRegister(ws, data) {
|
|
const { stationName, stationCode, stationPlatform, stationVersion } = data;
|
|
const cleanName = stationName.trim();
|
|
|
|
stations[cleanName] = {
|
|
name: cleanName,
|
|
code: stationCode,
|
|
platform: stationPlatform || getPlatformNumberFromName(cleanName),
|
|
version: stationVersion || '',
|
|
lastSeen: Date.now(),
|
|
ws: ws // Keep reference if we need to send back
|
|
};
|
|
normalizeStationRecord(stations[cleanName]);
|
|
|
|
log('SUCCESS', `Station registered: "${cleanName}" (${stationCode})${stationVersion ? ` ${stationVersion}` : ''}`);
|
|
|
|
// Send ack
|
|
ws.send(JSON.stringify({
|
|
type: 'station_register_ack',
|
|
stationName: cleanName
|
|
}));
|
|
|
|
saveData();
|
|
broadcastUpdate();
|
|
}
|
|
|
|
function getBaseName(name) {
|
|
if (!name) return "";
|
|
return String(name).toLowerCase()
|
|
.replace(/^[\d\w]+[-_][\d\w]+\s+/, '') // Strip leading code "01-01 "
|
|
.replace(/\s*platform\s*[\d\w]+/gi, '')
|
|
.replace(/\s*track\s*[\d\w]+/gi, '')
|
|
.replace(/\s*站台\s*[\d\w]+/gi, '')
|
|
.replace(/\s*股道\s*[\d\w]+/gi, '')
|
|
.replace(/@/g, ' ')
|
|
.replace(/\s*(station|站|tread|track)$/gi, '')
|
|
.replace(/[\(\[].*?[\)\]]/g, ' ')
|
|
.replace(/\s+\d+$/g, '')
|
|
.replace(/[^\w\s\u4e00-\u9fa5]/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function isFuzzyMatch(str, pattern) {
|
|
if (!str || !pattern) return false;
|
|
const s = String(str).toLowerCase().trim();
|
|
const p = String(pattern).toLowerCase().trim();
|
|
|
|
if (s === p) return true;
|
|
|
|
// 1. Wildcard match
|
|
if (p.includes('*')) {
|
|
const regex = new RegExp('^' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*') + '$', 'i');
|
|
if (regex.test(s)) return true;
|
|
}
|
|
|
|
// 2. Base name comparison
|
|
const b1 = getBaseName(s);
|
|
const b2 = getBaseName(p);
|
|
if (b1 && b2 && b1 === b2) return true;
|
|
|
|
// 3. Substring match of base names (if one is long enough)
|
|
if (b1 && b2) {
|
|
if (b1.length >= 4 && b2.length >= 4) {
|
|
if (b1.includes(b2) || b2.includes(b1)) return true;
|
|
}
|
|
}
|
|
|
|
// 4. Stripped comparison
|
|
const s1 = s.replace(/[\s\-\._\(\)\[\]]/g, '');
|
|
const s2 = p.replace(/[\s\-\._\(\)\[\]]/g, '');
|
|
if (s1 && s2 && s1 === s2) return true;
|
|
|
|
// 5. Station code comparison
|
|
const c1 = getStationCodeFromName(s);
|
|
const c2 = getStationCodeFromName(p);
|
|
if (c1 && c2 && c1 === c2) {
|
|
// Only trust code match if it looks like a real code (contains numbers or is very short)
|
|
const isRealCode = (str) => /[\d]/.test(str) || str.length <= 4;
|
|
if (isRealCode(c1)) return true;
|
|
|
|
// If it's a long name-based code, also require base name similarity
|
|
if (b1 && b2 && (b1.includes(b2) || b2.includes(b1))) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function findStationByFlexibleName(targetName) {
|
|
if (!targetName) return null;
|
|
|
|
// 1. Try exact match
|
|
if (stations[targetName]) return stations[targetName];
|
|
|
|
// 2. Search through registered stations using fuzzy match
|
|
for (const [regName, data] of Object.entries(stations)) {
|
|
if (isFuzzyMatch(regName, targetName)) {
|
|
// log('DEBUG', `[Station-Search] Matched "${targetName}" to registered "${regName}"`);
|
|
return data;
|
|
}
|
|
}
|
|
|
|
// Log available stations for debugging
|
|
const available = Object.keys(stations).map(k => `"${k}" (base: "${getBaseName(k)}")`).join(', ');
|
|
log('DEBUG', `[Station-Search] Could not find match for "${targetName}" (base: "${getBaseName(targetName)}"). Available: [${available}]`);
|
|
return null;
|
|
}
|
|
|
|
function handleTrainArrival(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 destCode = data.destCode || getStationCodeFromName(data.destination) || 'UNK';
|
|
const nextCode = data.nextCode || getStationCodeFromName(data.nextStation) || 'UNK';
|
|
|
|
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...`);
|
|
saveData();
|
|
broadcastUpdate();
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'train_arrival_ack',
|
|
success: true,
|
|
message: 'Train arrival processed successfully',
|
|
trainId
|
|
}));
|
|
|
|
// Broadcast Audio Logic
|
|
const seq = [
|
|
'ksts',
|
|
`ja-${platform}`, 'ja-zt', `ja-${lineCode}`, 'ja-xl', `ja-${destCode}`, 'ja-to', 'ja-nextsta', `ja-${nextCode}`, 'ja-warn',
|
|
`ch-${platform}`, 'ch-zt', `ch-${lineCode}`, 'ch-line', 'ch-to', `ch-${destCode}`, 'ch-nextsta', `ch-${nextCode}`, 'ch-warn'
|
|
];
|
|
|
|
enqueueHlsPush(stationName, seq);
|
|
|
|
const audioId = `arrival-${stationCode}-${Date.now()}`;
|
|
const built = buildSequenceWavFile(seq);
|
|
if (built) {
|
|
enqueueStationAudioTask(stationName, ws, {
|
|
audioFormat: built.audioFormat,
|
|
streamUrl: built.streamUrl,
|
|
durationSec: built.durationSec,
|
|
priority: 1,
|
|
interruptMode: 'ifLower',
|
|
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 beep = data.departureBeep || data.beep || 'fc1';
|
|
|
|
const existingIndex = activeTrains.findIndex(t => t.trainId === trainId);
|
|
if (existingIndex >= 0) {
|
|
activeTrains.splice(existingIndex, 1);
|
|
}
|
|
|
|
log('INFO', `Train ${trainId} departing from ${stationName}. Broadcasting departure...`);
|
|
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'
|
|
];
|
|
|
|
enqueueHlsPush(stationName, seq);
|
|
|
|
const audioId = `departure-${stationCode}-${Date.now()}`;
|
|
const built = buildSequenceWavFile(seq);
|
|
if (built) {
|
|
enqueueStationAudioTask(stationName, ws, {
|
|
audioFormat: built.audioFormat,
|
|
streamUrl: built.streamUrl,
|
|
durationSec: built.durationSec,
|
|
priority: 1,
|
|
interruptMode: 'ifLower',
|
|
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
|
|
};
|
|
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} 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/station-audio', upload.array('stationAudioFiles'), (req, res) => {
|
|
const files = req.files || [];
|
|
log('SUCCESS', `Uploaded ${files.length} station audio files`);
|
|
res.json({ success: true, message: `${files.length} station audio files uploaded` });
|
|
});
|
|
|
|
app.get('/api/files', (req, res) => {
|
|
res.json({ files: getManagedFiles() });
|
|
});
|
|
|
|
app.delete('/api/files', (req, res) => {
|
|
const filePath = req.query.path;
|
|
if (!filePath) return res.status(400).json({ success: false, message: 'Path required' });
|
|
|
|
const resolved = resolveManagedPath(filePath);
|
|
if (!resolved) return res.status(403).json({ success: false, message: 'Invalid path' });
|
|
const { fullPath, logicalPath } = resolved;
|
|
|
|
if (fs.existsSync(fullPath)) {
|
|
try {
|
|
fs.unlinkSync(fullPath);
|
|
log('INFO', `Deleted file: ${logicalPath}`);
|
|
|
|
// Also try to delete counterpart (.dfpwm if .wav deleted, or vice versa)
|
|
if (logicalPath.endsWith('.wav')) {
|
|
const dfpwmPath = fullPath.replace(/\.wav$/, '.dfpwm');
|
|
if (fs.existsSync(dfpwmPath)) fs.unlinkSync(dfpwmPath);
|
|
} else if (logicalPath.endsWith('.dfpwm')) {
|
|
const wavPath = fullPath.replace(/\.dfpwm$/, '.wav');
|
|
if (fs.existsSync(wavPath)) fs.unlinkSync(wavPath);
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (e) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
} else {
|
|
res.status(404).json({ success: false, message: 'File not found' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/stations', (req, res) => {
|
|
const list = getMergedStationList()
|
|
.sort((a, b) => String(a.code || '').localeCompare(String(b.code || '')) || String(a.name || '').localeCompare(String(b.name || '')));
|
|
res.json({ success: true, stations: list });
|
|
});
|
|
|
|
app.post('/api/stations', express.json(), (req, res) => {
|
|
const name = String(req.body?.name || '').trim();
|
|
const code = req.body?.code === undefined ? '' : String(req.body.code).trim();
|
|
if (!name) return res.status(400).json({ success: false, message: 'Station name required' });
|
|
if (stations[name]) return res.status(409).json({ success: false, message: 'Station already exists' });
|
|
|
|
stations[name] = {
|
|
name,
|
|
code,
|
|
platform: getPlatformNumberFromName(name),
|
|
lastSeen: Date.now()
|
|
};
|
|
normalizeStationRecord(stations[name]);
|
|
saveData();
|
|
broadcastUpdate();
|
|
res.json({ success: true, station: { name, code, lastSeen: stations[name].lastSeen } });
|
|
});
|
|
|
|
app.put('/api/stations/:name', express.json(), (req, res) => {
|
|
const name = req.params.name;
|
|
const st = stations[name];
|
|
if (!st) return res.status(404).json({ success: false, message: 'Station not found' });
|
|
|
|
const newName = req.body?.newName === undefined ? '' : String(req.body.newName).trim();
|
|
const nextCode = req.body?.code === undefined ? undefined : String(req.body.code).trim();
|
|
|
|
let targetName = name;
|
|
if (newName && newName !== name) {
|
|
if (stations[newName]) return res.status(409).json({ success: false, message: 'New station name already exists' });
|
|
stations[newName] = { ...st, name: newName };
|
|
delete stations[name];
|
|
targetName = newName;
|
|
}
|
|
|
|
if (nextCode !== undefined) stations[targetName].code = nextCode;
|
|
stations[targetName].platform = getPlatformNumberFromName(stations[targetName].name);
|
|
stations[targetName].lastSeen = stations[targetName].lastSeen || Date.now();
|
|
normalizeStationRecord(stations[targetName]);
|
|
|
|
saveData();
|
|
broadcastUpdate();
|
|
res.json({ success: true, station: { name: stations[targetName].name, code: stations[targetName].code, lastSeen: stations[targetName].lastSeen } });
|
|
});
|
|
|
|
app.delete('/api/stations/:name', (req, res) => {
|
|
const selector = String(req.params.name || '').trim();
|
|
const group = resolveStationGroup(selector);
|
|
if (group && Array.isArray(group.memberNames) && group.memberNames.length > 0) {
|
|
for (const memberName of group.memberNames) {
|
|
delete stations[memberName];
|
|
}
|
|
log('INFO', `Deleted station target: ${selector} (${group.memberNames.length} node(s))`);
|
|
saveData();
|
|
broadcastUpdate();
|
|
res.json({ success: true });
|
|
} else {
|
|
res.status(404).json({ success: false, message: 'Station not found' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/streams/:streamName', (req, res) => {
|
|
const streamName = String(req.params.streamName || '').trim();
|
|
if (!streamName) return res.status(400).json({ success: false, message: 'Stream name required' });
|
|
const state = hlsStreams[streamName];
|
|
if (!state) return res.status(404).json({ success: false, message: 'Stream not found' });
|
|
if (state.currentProcess) {
|
|
try { state.currentProcess.kill('SIGKILL'); } catch {}
|
|
}
|
|
delete hlsStreams[streamName];
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.delete('/api/trains/:id', (req, res) => {
|
|
const id = req.params.id;
|
|
const initialLength = activeTrains.length;
|
|
activeTrains = activeTrains.filter(t => t.trainId !== id);
|
|
|
|
if (activeTrains.length < initialLength) {
|
|
log('INFO', `Deleted train: ${id}`);
|
|
saveData();
|
|
broadcastUpdate();
|
|
res.json({ success: true });
|
|
} else {
|
|
res.status(404).json({ success: false, message: 'Train not found' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/files/content', (req, res) => {
|
|
const filePath = req.query.path;
|
|
if (!filePath) return res.status(400).json({ success: false, message: 'Path required' });
|
|
|
|
const resolved = resolveManagedPath(filePath);
|
|
if (!resolved) return res.status(403).json({ success: false, message: 'Invalid path' });
|
|
const { fullPath } = resolved;
|
|
|
|
if (fs.existsSync(fullPath)) {
|
|
try {
|
|
const content = fs.readFileSync(fullPath, 'utf8');
|
|
res.json({ success: true, content });
|
|
} catch (e) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
} else {
|
|
res.status(404).json({ success: false, message: 'File not found' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/files/content', express.json(), (req, res) => {
|
|
const { path: filePath, content } = req.body;
|
|
if (!filePath || content === undefined) return res.status(400).json({ success: false, message: 'Missing parameters' });
|
|
|
|
const resolved = resolveManagedPath(filePath);
|
|
if (!resolved) return res.status(403).json({ success: false, message: 'Invalid path' });
|
|
const { fullPath, logicalPath } = resolved;
|
|
|
|
try {
|
|
fs.writeFileSync(fullPath, content, 'utf8');
|
|
log('INFO', `Updated file: ${logicalPath}`);
|
|
res.json({ success: true });
|
|
} catch (e) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
// TTS Generation Proxy
|
|
const tls = require('tls');
|
|
|
|
function requestViaProxy(urlStr, options, proxyUrlStr) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(urlStr);
|
|
const proxyUrl = new URL(proxyUrlStr);
|
|
|
|
const proxyReq = http.request({
|
|
host: proxyUrl.hostname,
|
|
port: proxyUrl.port,
|
|
method: 'CONNECT',
|
|
path: url.hostname + ':' + (url.port || 443),
|
|
});
|
|
|
|
proxyReq.on('connect', (res, socket, head) => {
|
|
if (res.statusCode !== 200) {
|
|
return reject(new Error('Proxy connection failed: ' + res.statusCode));
|
|
}
|
|
const tlsSocket = tls.connect({
|
|
socket: socket,
|
|
servername: url.hostname
|
|
}, () => {
|
|
const req = require('https').request({
|
|
host: url.hostname,
|
|
port: url.port || 443,
|
|
path: url.pathname + url.search,
|
|
method: options.method || 'GET',
|
|
headers: options.headers,
|
|
createConnection: () => tlsSocket
|
|
}, (res) => {
|
|
let body = [];
|
|
res.on('data', chunk => body.push(chunk));
|
|
res.on('end', () => resolve({
|
|
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
status: res.statusCode,
|
|
buffer: Buffer.concat(body),
|
|
text: Buffer.concat(body).toString('utf8')
|
|
}));
|
|
});
|
|
|
|
req.on('error', reject);
|
|
if (options.body) req.write(options.body);
|
|
req.end();
|
|
});
|
|
tlsSocket.on('error', reject);
|
|
});
|
|
|
|
proxyReq.on('error', reject);
|
|
proxyReq.end();
|
|
});
|
|
}
|
|
|
|
function requestDirect(urlStr, options) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(urlStr);
|
|
const req = require('https').request({
|
|
host: url.hostname,
|
|
port: url.port || 443,
|
|
path: url.pathname + url.search,
|
|
method: options.method || 'GET',
|
|
headers: options.headers
|
|
}, (res) => {
|
|
let body = [];
|
|
res.on('data', chunk => body.push(chunk));
|
|
res.on('end', () => resolve({
|
|
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
status: res.statusCode,
|
|
buffer: Buffer.concat(body),
|
|
text: Buffer.concat(body).toString('utf8')
|
|
}));
|
|
});
|
|
|
|
req.on('error', reject);
|
|
if (options.body) req.write(options.body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
async function generateTtsWavToTemp(text, reference_id, 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 && os.platform() === 'win32') {
|
|
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;
|
|
if (!proxyUrl && os.platform() === 'win32') {
|
|
proxyUrl = 'http://127.0.0.1:7890';
|
|
}
|
|
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
|
|
};
|
|
|
|
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 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 });
|
|
}
|
|
});
|
|
|
|
// 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 = String(stationCode).replace(/\//g, '-');
|
|
|
|
const targetDir = type === 'line' ? audioBeepDir : audioStationDir;
|
|
const zhDest = path.join(targetDir, `ch-${safeStationCode}.wav`);
|
|
const jaDest = path.join(targetDir, `ja-${safeStationCode}.wav`);
|
|
|
|
try {
|
|
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 candidates = [
|
|
...getBeepAudioDirs().map(dir => path.join(dir, `${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_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: `/uploads/temp-broadcast/${path.basename(built.wavPath)}`,
|
|
durationSec: built.durationSec
|
|
});
|
|
} catch (e) {
|
|
if (ttsTempPath) {
|
|
try { fs.unlinkSync(ttsTempPath); } catch {}
|
|
}
|
|
res.status(Number(e?.status) || 500).json({ success: false, message: e?.message || String(e) });
|
|
}
|
|
});
|
|
|
|
app.post('/api/temp-broadcast/publish', (req, res) => {
|
|
const broadcastId = String(req.body?.broadcastId || '').trim();
|
|
const urgency = String(req.body?.urgency || 'normal').trim().toLowerCase();
|
|
const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : [];
|
|
|
|
if (!broadcastId) return res.status(400).json({ success: false, message: 'broadcastId required' });
|
|
if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' });
|
|
if (urgency !== 'normal' && urgency !== 'emergency') return res.status(400).json({ success: false, message: 'Invalid urgency' });
|
|
|
|
const wavPath = path.join(tempBroadcastDir, `temp_${broadcastId}.wav`);
|
|
if (!fs.existsSync(wavPath)) return res.status(404).json({ success: false, message: 'Temp broadcast not found (wav)' });
|
|
|
|
const wavBuf = fs.readFileSync(wavPath);
|
|
const durationSec = getWavDurationSec(wavBuf) || 0;
|
|
const streamUrl = getManagedMediaUrl('temp', path.basename(wavPath));
|
|
const priority = urgency === 'emergency' ? 2 : 0;
|
|
const interruptMode = urgency === 'emergency' ? 'all' : 'none';
|
|
const source = urgency === 'emergency' ? 'temp_emergency' : 'temp_normal';
|
|
const requeueOnPreempt = urgency !== 'emergency';
|
|
|
|
let sent = 0;
|
|
let failed = 0;
|
|
const results = [];
|
|
|
|
for (const 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: 'stream',
|
|
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: -1,
|
|
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: -1,
|
|
interruptMode: 'none',
|
|
source: 'music',
|
|
loop,
|
|
audioId: `music-${track}-${memberName}-${Date.now()}`,
|
|
requeueOnPreempt: true,
|
|
consoleStreamStart: idx === 0 ? { type: 'stream_start', station: group.name, seq: [`MUSIC:${track}`], streamName, hlsUrl } : null,
|
|
consoleStreamEnd: idx === 0 ? { type: 'stream_end', station: group.name } : null
|
|
});
|
|
};
|
|
|
|
enqueueOnce();
|
|
}
|
|
sent++;
|
|
results.push({ stationName: group.name, ok: true, memberCount: onlineMembers.length, totalMembers: group.memberCount });
|
|
}
|
|
|
|
res.json({ success: true, sent, failed, results });
|
|
});
|
|
|
|
app.post('/api/music/stop', express.json(), (req, res) => {
|
|
const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : [];
|
|
if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' });
|
|
|
|
let stopped = 0;
|
|
const results = [];
|
|
|
|
for (const group of expandStationTargets(stationsReq)) {
|
|
for (const memberName of group.memberNames) {
|
|
const ws = getStationWs(memberName);
|
|
stationMusicStates.set(memberName, { enabled: false, track: null, loop: false });
|
|
stopStationMusic(memberName, ws);
|
|
}
|
|
stopped++;
|
|
results.push({ stationName: group.name, ok: true, memberCount: group.memberCount });
|
|
}
|
|
|
|
res.json({ success: true, stopped, results });
|
|
});
|
|
|
|
// Error handling middleware
|
|
app.use((err, req, res, next) => {
|
|
log('ERROR', `Unhandled error: ${err.message}`);
|
|
res.status(err.status || 500).json({
|
|
success: false,
|
|
message: err.message || 'Internal Server Error'
|
|
});
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
loadData();
|
|
if (!audioVersion) audioVersion = String(Date.now());
|
|
if (!Array.isArray(versions) || versions.length === 0) {
|
|
const snap = snapshotFiles();
|
|
const changes = diffSnapshots(null, snap);
|
|
versions = [{
|
|
id: String(Date.now()),
|
|
version: audioVersion,
|
|
note: '',
|
|
createdAt: Date.now(),
|
|
snapshot: snap,
|
|
changes
|
|
}];
|
|
saveData();
|
|
}
|
|
console.log(`Cloud Server running on port ${PORT}`);
|
|
});
|