Download the update helper during install and expose it from the cloud server so new hosts can run manual updates immediately.
3212 lines
116 KiB
JavaScript
3212 lines
116 KiB
JavaScript
|
|
const express = require('express');
|
|
const http = require('http');
|
|
const WebSocket = require('ws');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const bodyParser = require('body-parser');
|
|
const multer = require('multer');
|
|
const fs = require('fs');
|
|
const { execSync, spawn } = require('child_process');
|
|
const dfpwm = require('dfpwm');
|
|
const { fetch } = require('undici');
|
|
|
|
const app = express();
|
|
const server = http.createServer(app);
|
|
const wss = new WebSocket.Server({ server });
|
|
|
|
const PORT = Number(process.env.PORT || '8999');
|
|
const DEFAULT_TTS_MODEL = process.env.TTS_MODEL || 's2.1-pro-free';
|
|
const DEFAULT_ZH_REFERENCE_ID = process.env.ZH_REFERENCE_ID || '0aee9f0f5683480d987c9e3c5d2de04c';
|
|
const DEFAULT_JA_REFERENCE_ID = process.env.JA_REFERENCE_ID || process.env.EN_REFERENCE_ID || '38985e545cff41b79c11c32b0196c9a0';
|
|
|
|
const HLS_ENABLE = process.env.HLS_ENABLE !== '0';
|
|
const HLS_STREAM_MODE = (process.env.HLS_STREAM_MODE || 'event').toLowerCase();
|
|
const HLS_OUTPUT_MODE = (process.env.HLS_OUTPUT_MODE || 'srs').toLowerCase();
|
|
const SRS_RTMP_BASE = process.env.SRS_RTMP_BASE || 'rtmp://127.0.0.1/live';
|
|
const STABROADCAST_PUBLIC_BASE_DEFAULT = 'https://stabroadcast.fse-media.group';
|
|
const STABROADCAST_PUBLIC_BASE = process.env.STABROADCAST_PUBLIC_BASE || STABROADCAST_PUBLIC_BASE_DEFAULT;
|
|
const STABROADCAST_API_BASE = process.env.STABROADCAST_API_BASE || 'http://stabadmin.fse-media.group';
|
|
const STABROADCAST_HLS_SUFFIX = process.env.STABROADCAST_HLS_SUFFIX || '.m3u8';
|
|
const HLS_TAIL_SILENCE_SEC = Number(process.env.HLS_TAIL_SILENCE_SEC || '0');
|
|
const HLS_PREBUILD = process.env.HLS_PREBUILD !== '0';
|
|
|
|
const appRootDir = path.resolve(__dirname, '..');
|
|
const tmpDir = path.join(__dirname, 'tmp');
|
|
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
|
const silence1sWavPath = path.join(tmpDir, 'silence-1s.wav');
|
|
|
|
const localHlsDir = path.join(__dirname, 'public', 'hls');
|
|
if (!fs.existsSync(localHlsDir)) fs.mkdirSync(localHlsDir, { recursive: true });
|
|
|
|
const uploadDir = path.join(__dirname, 'public', 'uploads');
|
|
const audioRootDir = path.join(appRootDir, 'Audio');
|
|
const audioMusicDir = path.join(audioRootDir, 'music');
|
|
const audioVoiceDir = path.join(audioRootDir, 'voice');
|
|
const audioLineDir = path.join(audioRootDir, 'line');
|
|
const audioBeepDir = path.join(audioRootDir, 'beep');
|
|
const audioGeneralLegacyDir = path.join(audioRootDir, 'general');
|
|
const audioStationDir = path.join(audioRootDir, 'station');
|
|
const listFilePath = path.join(appRootDir, 'list.txt');
|
|
const tempBroadcastDir = path.join(appRootDir, 'temp-broadcast');
|
|
const stationScriptPath = path.join(appRootDir, 'station_broadcast.lua');
|
|
const stationUpdateScriptPath = path.join(appRootDir, 'update_station_broadcast.lua');
|
|
const STATION_SCRIPT_DOWNLOAD_PATH = '/downloads/station_broadcast.lua';
|
|
const STATION_UPDATE_SCRIPT_DOWNLOAD_PATH = '/downloads/update_station_broadcast.lua';
|
|
const STREAMABLE_AUDIO_EXTENSIONS = ['.wav', '.mp3', '.ogg', '.flac', '.m4a', '.aac', '.opus'];
|
|
const MUSIC_AUDIO_EXTENSIONS = [...STREAMABLE_AUDIO_EXTENSIONS, '.dfpwm'];
|
|
|
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
|
if (!fs.existsSync(audioRootDir)) fs.mkdirSync(audioRootDir, { recursive: true });
|
|
if (!fs.existsSync(audioMusicDir)) fs.mkdirSync(audioMusicDir, { recursive: true });
|
|
if (!fs.existsSync(audioVoiceDir)) fs.mkdirSync(audioVoiceDir, { recursive: true });
|
|
if (!fs.existsSync(audioLineDir)) fs.mkdirSync(audioLineDir, { recursive: true });
|
|
if (!fs.existsSync(audioBeepDir)) fs.mkdirSync(audioBeepDir, { recursive: true });
|
|
if (!fs.existsSync(audioStationDir)) fs.mkdirSync(audioStationDir, { recursive: true });
|
|
if (!fs.existsSync(tempBroadcastDir)) fs.mkdirSync(tempBroadcastDir, { recursive: true });
|
|
|
|
function getBeepAudioDirs() {
|
|
const dirs = [audioBeepDir];
|
|
if (audioGeneralLegacyDir !== audioBeepDir && fs.existsSync(audioGeneralLegacyDir)) {
|
|
dirs.push(audioGeneralLegacyDir);
|
|
}
|
|
return dirs;
|
|
}
|
|
|
|
function sendBinaryFileFromCandidates(res, candidates) {
|
|
for (const filePath of candidates) {
|
|
if (fs.existsSync(filePath)) {
|
|
return res.sendFile(filePath);
|
|
}
|
|
}
|
|
return res.status(404).send('Not found');
|
|
}
|
|
|
|
// Multer storage configuration
|
|
const storage = multer.diskStorage({
|
|
destination: function (req, file, cb) {
|
|
if (file.fieldname === 'listFile') {
|
|
cb(null, appRootDir);
|
|
} else if (file.fieldname === 'musicAudioFiles') {
|
|
cb(null, audioMusicDir);
|
|
} else if (file.fieldname === 'voiceAudioFiles') {
|
|
cb(null, audioVoiceDir);
|
|
} else if (file.fieldname === 'lineAudioFiles') {
|
|
cb(null, audioLineDir);
|
|
} else if (file.fieldname === 'generalAudioFiles') {
|
|
cb(null, audioBeepDir);
|
|
} else if (file.fieldname === 'stationAudioFiles') {
|
|
cb(null, audioStationDir);
|
|
} else {
|
|
cb(null, uploadDir);
|
|
}
|
|
},
|
|
filename: function (req, file, cb) {
|
|
if (file.fieldname === 'listFile') {
|
|
cb(null, 'list.txt');
|
|
} else {
|
|
// Use original name, handle encoding if needed (multer handles basic utf8)
|
|
file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8');
|
|
cb(null, file.originalname);
|
|
}
|
|
}
|
|
});
|
|
|
|
const upload = multer({ storage: storage });
|
|
|
|
// Middleware
|
|
app.use(bodyParser.json());
|
|
app.use((req, res, next) => {
|
|
if (req.path.endsWith('.m3u8')) res.type('application/vnd.apple.mpegurl');
|
|
else if (req.path.endsWith('.ts')) res.type('video/mp2t');
|
|
next();
|
|
});
|
|
|
|
function sendListFile(res) {
|
|
if (!fs.existsSync(listFilePath)) {
|
|
return res.status(404).send('list.txt not found');
|
|
}
|
|
res.type('text/plain; charset=utf-8');
|
|
res.send(fs.readFileSync(listFilePath, 'utf8'));
|
|
}
|
|
|
|
app.get('/list.txt', (req, res) => sendListFile(res));
|
|
app.get('/uploads/list.txt', (req, res) => sendListFile(res));
|
|
|
|
function sendBinaryFile(res, filePath) {
|
|
if (!fs.existsSync(filePath)) return res.status(404).send('Not found');
|
|
res.sendFile(filePath);
|
|
}
|
|
|
|
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/line/:file', (req, res) => {
|
|
const name = path.basename(String(req.params.file || ''));
|
|
sendBinaryFile(res, path.join(audioLineDir, 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/line/:file',
|
|
'/media/audio/beep/:file',
|
|
'/media/audio/general/:file',
|
|
'/media/audio/station/:file',
|
|
'/media/temp/:file'
|
|
]
|
|
});
|
|
});
|
|
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
app.set('view engine', 'ejs');
|
|
|
|
// Data Store (In-memory)
|
|
let stations = {};
|
|
let activeTrains = [];
|
|
let stationLogs = [];
|
|
let audioVersion = String(Date.now());
|
|
let versions = [];
|
|
let stationUpdate = {
|
|
version: '',
|
|
releaseId: '',
|
|
publishedAt: 0
|
|
};
|
|
|
|
const DATA_FILE = path.join(__dirname, 'server-data.json');
|
|
|
|
const stationAudioQueues = new Map();
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function getLocalStationScriptVersion() {
|
|
try {
|
|
if (!fs.existsSync(stationScriptPath)) return '';
|
|
const content = fs.readFileSync(stationScriptPath, 'utf8');
|
|
const match = content.match(/local\s+APP_VERSION_TEXT\s*=\s*["']([^"']+)["']/);
|
|
return match ? String(match[1]).trim() : '';
|
|
} catch (e) {
|
|
log('ERROR', `Failed to read station script version: ${e.message}`);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function buildAbsoluteUrl(req, pathname) {
|
|
const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim();
|
|
const host = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim();
|
|
if (!host) return pathname;
|
|
return `${proto}://${host}${pathname}`;
|
|
}
|
|
|
|
function getStationUpdateSummary(req) {
|
|
return {
|
|
localVersion: getLocalStationScriptVersion(),
|
|
targetVersion: stationUpdate.version || '',
|
|
releaseId: stationUpdate.releaseId || '',
|
|
publishedAt: Number(stationUpdate.publishedAt || 0),
|
|
downloadPath: STATION_SCRIPT_DOWNLOAD_PATH,
|
|
downloadUrl: buildAbsoluteUrl(req, STATION_SCRIPT_DOWNLOAD_PATH)
|
|
};
|
|
}
|
|
|
|
function maybeSendStationUpdate(ws, stationName, stationVersion) {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
if (!stationUpdate.releaseId || !stationUpdate.version) return false;
|
|
if (String(stationVersion || '').trim() === String(stationUpdate.version || '').trim()) return false;
|
|
|
|
try {
|
|
ws.send(JSON.stringify({
|
|
type: 'station_update',
|
|
version: stationUpdate.version,
|
|
releaseId: stationUpdate.releaseId,
|
|
publishedAt: Number(stationUpdate.publishedAt || 0),
|
|
downloadPath: STATION_SCRIPT_DOWNLOAD_PATH
|
|
}));
|
|
log('INFO', `Queued station update for "${stationName}" -> ${stationUpdate.version}`);
|
|
return true;
|
|
} catch (e) {
|
|
log('ERROR', `Failed to push station update to "${stationName}": ${e.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function publishStationUpdate(req) {
|
|
const version = getLocalStationScriptVersion() || `build-${Date.now()}`;
|
|
const releaseId = String(Date.now());
|
|
stationUpdate = {
|
|
version,
|
|
releaseId,
|
|
publishedAt: Date.now()
|
|
};
|
|
saveData();
|
|
|
|
let onlineStations = 0;
|
|
let notifiedStations = 0;
|
|
for (const [name, station] of Object.entries(stations)) {
|
|
const ws = station?.ws;
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) continue;
|
|
onlineStations++;
|
|
if (maybeSendStationUpdate(ws, name, station.version)) {
|
|
notifiedStations++;
|
|
}
|
|
}
|
|
|
|
const summary = getStationUpdateSummary(req);
|
|
broadcastUpdate();
|
|
broadcastToConsole({
|
|
type: 'event',
|
|
title: '站点更新已发布',
|
|
message: `目标版本 ${summary.targetVersion || version},已通知 ${notifiedStations}/${onlineStations} 台在线主机。`
|
|
});
|
|
log('INFO', `Station update published: ${version} (${notifiedStations}/${onlineStations} online stations notified)`);
|
|
|
|
return {
|
|
...summary,
|
|
onlineStations,
|
|
notifiedStations
|
|
};
|
|
}
|
|
|
|
function getInterruptModeForPriority(priority) {
|
|
if (priority >= 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,
|
|
version: st.version || '',
|
|
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;
|
|
group.version = st.version || group.version || '';
|
|
} else if (!group.version && st.version) {
|
|
group.version = st.version;
|
|
}
|
|
}
|
|
|
|
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;
|
|
if (data.stationUpdate && typeof data.stationUpdate === 'object') {
|
|
stationUpdate = {
|
|
version: String(data.stationUpdate.version || ''),
|
|
releaseId: String(data.stationUpdate.releaseId || ''),
|
|
publishedAt: Number(data.stationUpdate.publishedAt || 0)
|
|
};
|
|
}
|
|
refreshStationIndex();
|
|
log('INFO', 'Data loaded from disk');
|
|
} catch (e) {
|
|
log('ERROR', `Failed to load data: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function saveData() {
|
|
try {
|
|
// 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,
|
|
stationUpdate
|
|
};
|
|
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
|
|
} catch (e) {
|
|
log('ERROR', `Failed to save data: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
function normalizeVersionString(input) {
|
|
if (input === undefined || input === null) return '';
|
|
if (typeof input !== 'string') input = String(input);
|
|
input = input.trim();
|
|
if (input.length > 64) input = input.slice(0, 64);
|
|
return input;
|
|
}
|
|
|
|
function resolveManagedPath(inputPath) {
|
|
const normalized = String(inputPath || '').replace(/\\/g, '/').replace(/^\/+/, '').trim();
|
|
if (!normalized) return null;
|
|
|
|
if (normalized === 'list.txt' || normalized === 'uploads/list.txt') {
|
|
return { logicalPath: 'list.txt', fullPath: listFilePath };
|
|
}
|
|
|
|
if (
|
|
normalized === 'Audio/beep/line.txt' ||
|
|
normalized === 'uploads/audio/beep/line.txt' ||
|
|
normalized === 'Audio/general/line.txt' ||
|
|
normalized === 'uploads/audio/general/line.txt'
|
|
) {
|
|
const fullPath = fs.existsSync(path.join(audioBeepDir, 'line.txt'))
|
|
? path.join(audioBeepDir, 'line.txt')
|
|
: path.join(audioGeneralLegacyDir, 'line.txt');
|
|
return { logicalPath: 'Audio/beep/line.txt', fullPath };
|
|
}
|
|
|
|
const prefixes = [
|
|
{ prefix: 'Audio/music/', baseDir: audioMusicDir, logicalPrefix: 'Audio/music/' },
|
|
{ prefix: 'Audio/voice/', baseDir: audioVoiceDir, logicalPrefix: 'Audio/voice/' },
|
|
{ prefix: 'Audio/beep/', baseDir: audioBeepDir, logicalPrefix: 'Audio/beep/' },
|
|
{ prefix: 'Audio/general/', baseDir: audioGeneralLegacyDir, logicalPrefix: 'Audio/beep/' },
|
|
{ prefix: 'uploads/audio/music/', baseDir: audioMusicDir, logicalPrefix: 'Audio/music/' },
|
|
{ prefix: 'uploads/audio/voice/', baseDir: audioVoiceDir, logicalPrefix: 'Audio/voice/' },
|
|
{ prefix: 'uploads/audio/beep/', baseDir: audioBeepDir, logicalPrefix: 'Audio/beep/' },
|
|
{ prefix: 'uploads/audio/general/', baseDir: audioGeneralLegacyDir, logicalPrefix: 'Audio/beep/' },
|
|
{ prefix: 'Audio/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
|
|
});
|
|
});
|
|
}
|
|
|
|
if (fs.existsSync(audioLineDir)) {
|
|
fs.readdirSync(audioLineDir).forEach(file => {
|
|
if (file.endsWith('.dfpwm')) {
|
|
const wavName = file.replace(/\.dfpwm$/, '.wav');
|
|
if (fs.existsSync(path.join(audioLineDir, wavName))) return;
|
|
}
|
|
const filePath = path.join(audioLineDir, file);
|
|
const stats = fs.statSync(filePath);
|
|
files.push({
|
|
name: file,
|
|
path: `Audio/line/${file}`,
|
|
type: 'line',
|
|
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 findFileCaseInsensitive(dir, filename) {
|
|
if (!fs.existsSync(dir)) return null;
|
|
const files = fs.readdirSync(dir);
|
|
const lowerFilename = filename.toLowerCase();
|
|
const match = files.find(f => f.toLowerCase() === lowerFilename);
|
|
return match ? path.join(dir, match) : null;
|
|
}
|
|
|
|
function resolveSequenceFiles(seq) {
|
|
ensureSilenceWav();
|
|
const files = [];
|
|
|
|
for (const item of seq) {
|
|
if (item === '__DELAY_1__') {
|
|
files.push(silence1sWavPath);
|
|
continue;
|
|
}
|
|
|
|
const dirsToSearch = [
|
|
audioStationDir,
|
|
audioLineDir,
|
|
audioVoiceDir,
|
|
...getBeepAudioDirs()
|
|
];
|
|
|
|
let found = null;
|
|
for (const dir of dirsToSearch) {
|
|
found = findFileCaseInsensitive(dir, `${item}.wav`);
|
|
if (found) break;
|
|
}
|
|
|
|
if (found) {
|
|
files.push(found);
|
|
} else {
|
|
log('WARN', `HLS audio missing on server (wav required): ${item}`);
|
|
}
|
|
}
|
|
|
|
if (HLS_TAIL_SILENCE_SEC > 0) {
|
|
for (let i = 0; i < HLS_TAIL_SILENCE_SEC; i++) files.push(silence1sWavPath);
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
function buildSequenceWavFile(seq) {
|
|
const files = resolveSequenceFiles(seq);
|
|
if (files.length === 0) return null;
|
|
|
|
const fileName = `sequence-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`;
|
|
const outWavPath = path.join(tempBroadcastDir, fileName);
|
|
|
|
const inputs = files.map(f => `-i "${f}"`).join(' ');
|
|
const filterResamples = files.map((_, i) => `[${i}:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a${i}]`).join(';');
|
|
const filterConcat = files.map((_, i) => `[a${i}]`).join('') + `concat=n=${files.length}:v=0:a=1[a]`;
|
|
const filterComplex = `${filterResamples};${filterConcat}`;
|
|
|
|
try {
|
|
execSync(`ffmpeg -hide_banner -loglevel error -y ${inputs} -filter_complex "${filterComplex}" -map "[a]" -ac 1 -ar 48000 -c:a pcm_s16le "${outWavPath}"`);
|
|
const buffer = fs.readFileSync(outWavPath);
|
|
const durationSec = getWavDurationSec(buffer) || 0;
|
|
setTimeout(() => {
|
|
try { fs.unlinkSync(outWavPath); } catch {}
|
|
}, Math.max(10 * 60 * 1000, Math.ceil(durationSec * 2000)));
|
|
return {
|
|
audioFormat: 'wav-stream-live',
|
|
sourcePath: outWavPath,
|
|
streamUrl: getManagedMediaUrl('temp', fileName),
|
|
durationSec
|
|
};
|
|
} catch (e) {
|
|
log('ERROR', `buildSequenceWavFile failed: ${e.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function runFfmpegRtmpPush(streamName, seq) {
|
|
const state = getOrCreateHlsStreamState(streamName);
|
|
const files = resolveSequenceFiles(seq);
|
|
if (files.length === 0) {
|
|
state.status = 'idle';
|
|
state.lastError = 'No playable wav files in sequence';
|
|
return Promise.resolve();
|
|
}
|
|
|
|
const rtmpUrl = `${SRS_RTMP_BASE.replace(/\/+$/g, '')}/${encodeURIComponent(streamName)}`;
|
|
|
|
state.lastSeq = seq;
|
|
state.lastError = null;
|
|
state.lastExitCode = null;
|
|
|
|
if (HLS_PREBUILD) {
|
|
const tmpAacPath = path.join(tmpDir, `prebuild-${Date.now()}-${Math.random().toString(16).slice(2)}.aac`);
|
|
const inputs = files.map(f => `-i "${f}"`).join(' ');
|
|
const filterResamples = files.map((_, i) => `[${i}:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a${i}]`).join(';');
|
|
const filterConcat = files.map((_, i) => `[a${i}]`).join('') + `concat=n=${files.length}:v=0:a=1[a]`;
|
|
const filterComplex = `${filterResamples};${filterConcat}`;
|
|
|
|
const buildArgs = [
|
|
'-hide_banner',
|
|
'-loglevel',
|
|
'error',
|
|
'-y'
|
|
];
|
|
|
|
files.forEach(f => { buildArgs.push('-i', f); });
|
|
|
|
buildArgs.push(
|
|
'-filter_complex',
|
|
filterComplex,
|
|
'-map',
|
|
'[a]',
|
|
'-ac',
|
|
'1',
|
|
'-ar',
|
|
'48000',
|
|
'-c:a',
|
|
'aac',
|
|
'-b:a',
|
|
'128k',
|
|
'-f',
|
|
'adts',
|
|
tmpAacPath
|
|
);
|
|
|
|
state.status = 'building';
|
|
state.lastStartAt = Date.now();
|
|
|
|
const buildProc = spawn('ffmpeg', buildArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
let buildStderr = '';
|
|
buildProc.stderr.on('data', (buf) => {
|
|
buildStderr += buf.toString('utf8');
|
|
if (buildStderr.length > 8000) buildStderr = buildStderr.slice(-8000);
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
buildProc.on('close', (buildCode) => {
|
|
if (buildCode !== 0) {
|
|
state.lastExitCode = buildCode;
|
|
state.status = 'idle';
|
|
state.lastEndAt = Date.now();
|
|
state.lastError = (buildStderr || `ffmpeg exited with code ${buildCode}`).trim();
|
|
log('ERROR', `HLS build failed for ${streamName}: ${state.lastError}`);
|
|
try { fs.unlinkSync(listPath); } catch {}
|
|
try { fs.unlinkSync(tmpAacPath); } catch {}
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const pushArgs = [
|
|
'-hide_banner',
|
|
'-loglevel',
|
|
'error',
|
|
'-re',
|
|
'-i',
|
|
tmpAacPath,
|
|
'-c:a',
|
|
'copy',
|
|
'-f',
|
|
'flv',
|
|
rtmpUrl
|
|
];
|
|
|
|
state.status = 'pushing';
|
|
const proc = spawn('ffmpeg', pushArgs, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
state.currentProcess = proc;
|
|
|
|
let stderr = '';
|
|
proc.stderr.on('data', (buf) => {
|
|
stderr += buf.toString('utf8');
|
|
if (stderr.length > 8000) stderr = stderr.slice(-8000);
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
state.lastExitCode = code;
|
|
state.status = 'idle';
|
|
state.lastEndAt = Date.now();
|
|
state.currentProcess = null;
|
|
if (code !== 0) {
|
|
state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim();
|
|
log('ERROR', `HLS push failed for ${streamName}: ${state.lastError}`);
|
|
}
|
|
try { fs.unlinkSync(tmpAacPath); } catch {}
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
const filterResamples = files.map((_, i) => `[${i}:a]aformat=sample_rates=48000:channel_layouts=mono,aresample=48000[a${i}]`).join(';');
|
|
const filterConcat = files.map((_, i) => `[a${i}]`).join('') + `concat=n=${files.length}:v=0:a=1[a]`;
|
|
const filterComplex = `${filterResamples};${filterConcat}`;
|
|
|
|
const args = [
|
|
'-hide_banner',
|
|
'-loglevel',
|
|
'error',
|
|
'-re'
|
|
];
|
|
|
|
files.forEach(f => { args.push('-i', f); });
|
|
|
|
args.push(
|
|
'-filter_complex',
|
|
filterComplex,
|
|
'-map',
|
|
'[a]',
|
|
'-ac',
|
|
'1',
|
|
'-ar',
|
|
'48000',
|
|
'-c:a',
|
|
'aac',
|
|
'-b:a',
|
|
'128k',
|
|
'-f',
|
|
'flv',
|
|
rtmpUrl
|
|
);
|
|
|
|
state.status = 'pushing';
|
|
state.lastStartAt = Date.now();
|
|
|
|
const proc = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
state.currentProcess = proc;
|
|
|
|
let stderr = '';
|
|
proc.stderr.on('data', (buf) => {
|
|
stderr += buf.toString('utf8');
|
|
if (stderr.length > 8000) stderr = stderr.slice(-8000);
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
proc.on('close', (code) => {
|
|
state.lastExitCode = code;
|
|
state.status = 'idle';
|
|
state.lastEndAt = Date.now();
|
|
state.currentProcess = null;
|
|
if (code !== 0) {
|
|
state.lastError = (stderr || `ffmpeg exited with code ${code}`).trim();
|
|
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: getMergedStationList(),
|
|
activeTrains,
|
|
logs: stationLogs,
|
|
stationUpdate: getStationUpdateSummary()
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// Handle Station Messages
|
|
switch (data.type) {
|
|
case 'station_register':
|
|
handleStationRegister(ws, data);
|
|
break;
|
|
case 'train_arrival':
|
|
handleTrainArrival(ws, data);
|
|
break;
|
|
case 'train_approaching':
|
|
handleTrainApproaching(ws, data);
|
|
break;
|
|
case 'train_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}` : ''}`);
|
|
maybeSendStationUpdate(ws, cleanName, data.stationVersion || stations[cleanName].version);
|
|
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]);
|
|
maybeSendStationUpdate(ws, cleanName, data.stationVersion || stations[cleanName].version);
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}));
|
|
|
|
maybeSendStationUpdate(ws, cleanName, stationVersion);
|
|
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 handleTrainApproaching(ws, data) {
|
|
const trainId = data.trainId;
|
|
const stationName = getCorrectStationName(data.stationName);
|
|
const stationCode = data.stationCode;
|
|
const platform = data.stationPlatform || data.platform || '1';
|
|
const cleanPlatform = String(platform).replace(/\D/g, ''); // 提取数字部分
|
|
const finalPlatform = cleanPlatform || '1';
|
|
|
|
const lineCode = data.lineCode || (stationCode ? stationCode.split('-')[0] : 'UNK');
|
|
const destCode = data.destCode || getStationCodeFromName(data.destination) || 'UNK';
|
|
const delay = Math.max(0, Number(data.delay) || 0); // 获取延误信息(分钟)
|
|
const cancelled = Boolean(data.cancelled);
|
|
const rawReason = String(data.reason || '').trim();
|
|
|
|
log('INFO', `Train ${trainId} approaching ${stationName} (Delay: ${delay} min). Broadcasting pre-arrival...`);
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'train_approaching_ack',
|
|
success: true,
|
|
message: 'Train approaching processed successfully',
|
|
trainId
|
|
}));
|
|
|
|
const seq = ['ksts'];
|
|
|
|
seq.push('ja-fc1', `ja-${finalPlatform}`, 'ja-fc2', `ja-${destCode}`, 'ja-fc3');
|
|
seq.push('ch-fc1', 'ch-fc2', 'ch-fc3', `ch-${destCode}`, 'ch-fc4');
|
|
|
|
const normalizeReason = (reason, lang) => {
|
|
let r = String(reason || '').trim();
|
|
if (!r || r === 'delay') r = 'default_delay';
|
|
if (lang === 'ch' && r === 'train') r = 'delaytrain';
|
|
return r;
|
|
};
|
|
|
|
if (cancelled) {
|
|
seq.push(
|
|
'ksts',
|
|
'ja-cancel1', `ja-${lineCode}`, 'ja-cancel2',
|
|
'ch-cancel1', `ch-${lineCode}`, 'ch-cancel2'
|
|
);
|
|
} else if (delay > 0) {
|
|
const d = Math.max(1, Math.round(delay));
|
|
const jaReason = normalizeReason(rawReason, 'ja');
|
|
const chReason = normalizeReason(rawReason, 'ch');
|
|
seq.push(
|
|
'ksts',
|
|
'ja-delay1', `ja-${lineCode}`, `ja-${jaReason}`, 'ja-delay2', `ja-${d}min`, 'ja-delay3',
|
|
'ch-delay1', `ch-${lineCode}`, 'ch-delay2', `ch-${chReason}`, 'ch-delay3', `ch-${d}`, 'ch-delay4'
|
|
);
|
|
}
|
|
|
|
enqueueHlsPush(stationName, seq);
|
|
|
|
const audioId = `approaching-${stationCode}-${Date.now()}`;
|
|
const built = buildSequenceWavFile(seq);
|
|
if (built) {
|
|
enqueueStationAudioTask(stationName, ws, {
|
|
audioFormat: built.audioFormat,
|
|
sourcePath: built.sourcePath,
|
|
streamUrl: built.streamUrl,
|
|
durationSec: built.durationSec,
|
|
priority: 0,
|
|
interruptMode: 'none',
|
|
source: 'train_approaching',
|
|
audioId,
|
|
consoleStreamStart: { type: 'stream_start', station: stationName, seq, streamName: getStationStreamName(stationName), hlsUrl: getStationHlsUrl(getStationStreamName(stationName)) },
|
|
consoleStreamEnd: { type: 'stream_end', station: stationName }
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleTrainArrival(ws, data) {
|
|
const trainId = data.trainId;
|
|
const stationName = getCorrectStationName(data.stationName);
|
|
const stationCode = data.stationCode;
|
|
const platform = data.stationPlatform || data.platform || '1';
|
|
const cleanPlatform = String(platform).replace(/\D/g, ''); // 提取数字部分
|
|
const finalPlatform = cleanPlatform || '1';
|
|
|
|
const lineCode = data.lineCode || (stationCode ? stationCode.split('-')[0] : 'UNK');
|
|
const destCode = data.destCode || getStationCodeFromName(data.destination) || 'UNK';
|
|
const 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-${finalPlatform}`, 'ja-zt', `ja-${lineCode}`, `ja-${destCode}`, 'ja-to', 'ja-nextsta', `ja-${nextCode}`, 'ja-warn',
|
|
`ch-${finalPlatform}`, 'ch-zt', `ch-${lineCode}`, '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,
|
|
sourcePath: built.sourcePath,
|
|
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,
|
|
sourcePath: built.sourcePath,
|
|
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,
|
|
stationUpdate: getStationUpdateSummary()
|
|
};
|
|
broadcastToConsole(state);
|
|
}
|
|
|
|
app.get(STATION_SCRIPT_DOWNLOAD_PATH, (req, res) => {
|
|
if (!fs.existsSync(stationScriptPath)) {
|
|
return res.status(404).send('station_broadcast.lua not found');
|
|
}
|
|
return res.sendFile(stationScriptPath);
|
|
});
|
|
|
|
app.get(STATION_UPDATE_SCRIPT_DOWNLOAD_PATH, (req, res) => {
|
|
if (!fs.existsSync(stationUpdateScriptPath)) {
|
|
return res.status(404).send('update_station_broadcast.lua not found');
|
|
}
|
|
return res.sendFile(stationUpdateScriptPath);
|
|
});
|
|
|
|
app.get('/api/streams', (req, res) => {
|
|
const streams = [];
|
|
const seen = new Set();
|
|
const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim();
|
|
const hostRaw = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || '').split(',')[0].trim();
|
|
const localBase = hostRaw ? `${proto}://${hostRaw}/hls` : '/hls';
|
|
const publicBaseResolved = HLS_OUTPUT_MODE === 'local'
|
|
? localBase
|
|
: String(process.env.STABROADCAST_PUBLIC_BASE || guessSrsHttpBase(req) || STABROADCAST_PUBLIC_BASE_DEFAULT);
|
|
|
|
Object.keys(stations).forEach(stationName => {
|
|
const st = stations[stationName];
|
|
const streamName = getStationStreamName(stationName);
|
|
const state = getOrCreateHlsStreamState(streamName);
|
|
state.stationName = stationName;
|
|
state.stationCode = getPrimaryStationCode(st?.code);
|
|
state.hlsUrl = HLS_OUTPUT_MODE === 'local'
|
|
? getLocalHlsUrlForRequest(streamName, req)
|
|
: getStationHlsUrlForRequest(streamName, req);
|
|
seen.add(streamName);
|
|
|
|
streams.push({
|
|
streamName,
|
|
stationName: state.stationName,
|
|
stationCode: state.stationCode,
|
|
hlsUrl: state.hlsUrl,
|
|
status: state.status,
|
|
lastStartAt: state.lastStartAt,
|
|
lastEndAt: state.lastEndAt,
|
|
lastSeq: state.lastSeq,
|
|
lastError: state.lastError,
|
|
lastExitCode: state.lastExitCode
|
|
});
|
|
});
|
|
|
|
Object.values(hlsStreams).forEach(state => {
|
|
if (seen.has(state.streamName)) return;
|
|
streams.push({
|
|
streamName: state.streamName,
|
|
stationName: state.stationName,
|
|
stationCode: state.stationCode,
|
|
hlsUrl: HLS_OUTPUT_MODE === 'local'
|
|
? getLocalHlsUrlForRequest(state.streamName, req)
|
|
: getStationHlsUrlForRequest(state.streamName, req),
|
|
status: state.status,
|
|
lastStartAt: state.lastStartAt,
|
|
lastEndAt: state.lastEndAt,
|
|
lastSeq: state.lastSeq,
|
|
lastError: state.lastError,
|
|
lastExitCode: state.lastExitCode
|
|
});
|
|
});
|
|
|
|
streams.sort((a, b) => String(a.streamName).localeCompare(String(b.streamName)));
|
|
|
|
res.json({
|
|
ok: true,
|
|
hlsEnable: HLS_ENABLE,
|
|
streamMode: HLS_STREAM_MODE,
|
|
publicBase: publicBaseResolved,
|
|
srsRtmpBase: SRS_RTMP_BASE,
|
|
streams
|
|
});
|
|
});
|
|
|
|
app.get('/stablist', (req, res) => {
|
|
res.redirect('/stablist.html');
|
|
});
|
|
|
|
// Routes for direct API access if needed
|
|
app.get('/', (req, res) => {
|
|
const host = String(req.headers.host || '').toLowerCase();
|
|
if (host.startsWith('stablist.fse-media.group')) {
|
|
res.sendFile(path.join(__dirname, 'public', 'stablist.html'));
|
|
return;
|
|
}
|
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
});
|
|
|
|
function convertToDfpwm(filePath) {
|
|
try {
|
|
if (!filePath.toLowerCase().endsWith('.wav')) return;
|
|
|
|
const rawPath = filePath.replace(/\.wav$/i, '.raw');
|
|
const dfpwmPath = filePath.replace(/\.wav$/i, '.dfpwm');
|
|
|
|
// Convert to raw signed 8-bit 48kHz mono using FFmpeg
|
|
// -y: overwrite output
|
|
// -v quiet: suppress output
|
|
// -f s8: signed 8-bit pcm
|
|
// -ac 1: mono
|
|
// -ar 48000: 48kHz
|
|
execSync(`ffmpeg -y -v quiet -i "${filePath}" -f s8 -acodec pcm_s8 -ac 1 -ar 48000 "${rawPath}"`);
|
|
|
|
if (fs.existsSync(rawPath)) {
|
|
const buffer = fs.readFileSync(rawPath);
|
|
|
|
// dfpwm.Encoder.encode requires a Buffer in Node.js
|
|
const encoder = new dfpwm.Encoder();
|
|
|
|
// We need to pass the raw buffer to the encoder. The buffer already contains 8-bit PCM data.
|
|
// Some versions of dfpwm throw "Argument #1 must be a Buffer" if it's an Array or TypedArray.
|
|
const encoded = encoder.encode(buffer);
|
|
|
|
fs.writeFileSync(dfpwmPath, encoded);
|
|
fs.unlinkSync(rawPath); // Clean up temp file
|
|
|
|
log('SUCCESS', `Converted ${path.basename(filePath)} to DFPWM (FFmpeg)`);
|
|
} else {
|
|
log('ERROR', `FFmpeg failed to generate raw file for ${path.basename(filePath)}`);
|
|
}
|
|
|
|
} catch (e) {
|
|
log('ERROR', `DFPWM conversion failed for ${path.basename(filePath)}: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// Routes
|
|
app.get('/api/version', (req, res) => {
|
|
res.json({ version: audioVersion });
|
|
});
|
|
|
|
app.get('/api/station-update', (req, res) => {
|
|
res.json({
|
|
success: true,
|
|
...getStationUpdateSummary(req)
|
|
});
|
|
});
|
|
|
|
app.post('/api/station-updates/publish', (req, res) => {
|
|
const result = publishStationUpdate(req);
|
|
res.json({
|
|
success: true,
|
|
...result
|
|
});
|
|
});
|
|
|
|
app.post('/api/publish-version', (req, res) => {
|
|
const version = normalizeVersionString(req.body?.version) || String(Date.now());
|
|
const snap = snapshotFiles();
|
|
const prev = versions.length > 0 ? versions[versions.length - 1].snapshot : null;
|
|
const changes = diffSnapshots(prev, snap);
|
|
const entry = {
|
|
id: String(Date.now()),
|
|
version,
|
|
note: normalizeVersionString(req.body?.note),
|
|
createdAt: Date.now(),
|
|
snapshot: snap,
|
|
changes
|
|
};
|
|
versions.push(entry);
|
|
audioVersion = version;
|
|
saveData();
|
|
log('INFO', `Audio version published: ${version}`);
|
|
res.json({ success: true, version });
|
|
});
|
|
|
|
app.get('/api/versions', (req, res) => {
|
|
const list = versions
|
|
.slice()
|
|
.reverse()
|
|
.map(v => ({
|
|
id: v.id,
|
|
version: v.version,
|
|
note: v.note || '',
|
|
createdAt: v.createdAt,
|
|
changes: v.changes || { added: [], modified: [], deleted: [] }
|
|
}));
|
|
res.json({ success: true, activeVersion: audioVersion, versions: list });
|
|
});
|
|
|
|
app.post('/api/versions', (req, res) => {
|
|
const version = normalizeVersionString(req.body?.version) || String(Date.now());
|
|
const snap = snapshotFiles();
|
|
const prev = versions.length > 0 ? versions[versions.length - 1].snapshot : null;
|
|
const changes = diffSnapshots(prev, snap);
|
|
const entry = {
|
|
id: String(Date.now()),
|
|
version,
|
|
note: normalizeVersionString(req.body?.note),
|
|
createdAt: Date.now(),
|
|
snapshot: snap,
|
|
changes
|
|
};
|
|
versions.push(entry);
|
|
audioVersion = version;
|
|
saveData();
|
|
log('INFO', `Audio version published: ${version}`);
|
|
res.json({ success: true, version, id: entry.id });
|
|
});
|
|
|
|
app.put('/api/versions/:id', (req, res) => {
|
|
const id = req.params.id;
|
|
const idx = versions.findIndex(v => v.id === id);
|
|
if (idx < 0) return res.status(404).json({ success: false, message: 'Version not found' });
|
|
|
|
const wasActive = versions[idx].version === audioVersion;
|
|
const nextVersion = normalizeVersionString(req.body?.version);
|
|
if (nextVersion) versions[idx].version = nextVersion;
|
|
const nextNote = normalizeVersionString(req.body?.note);
|
|
if (req.body?.note !== undefined) versions[idx].note = nextNote;
|
|
|
|
if (wasActive) audioVersion = versions[idx].version;
|
|
|
|
saveData();
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.get('/api/versions/:id/changes', (req, res) => {
|
|
const id = req.params.id;
|
|
const entry = versions.find(v => v.id === id);
|
|
if (!entry) return res.status(404).json({ success: false, message: 'Version not found' });
|
|
res.json({ success: true, changes: entry.changes || { added: [], modified: [], deleted: [] } });
|
|
});
|
|
|
|
app.get('/api/versions/:id/files', (req, res) => {
|
|
const id = req.params.id;
|
|
const entry = versions.find(v => v.id === id);
|
|
if (!entry) return res.status(404).json({ success: false, message: 'Version not found' });
|
|
res.json({ success: true, files: entry.snapshot || {} });
|
|
});
|
|
|
|
app.post('/api/save-data', (req, res) => {
|
|
saveData();
|
|
log('INFO', 'Manual data save triggered');
|
|
res.json({ success: true });
|
|
});
|
|
|
|
app.post('/api/upload/list', upload.single('listFile'), (req, res) => {
|
|
log('SUCCESS', 'Updated list.txt');
|
|
res.json({ success: true, message: 'list.txt updated' });
|
|
});
|
|
|
|
app.post('/api/upload/general-audio', upload.array('generalAudioFiles'), (req, res) => {
|
|
const files = req.files || [];
|
|
log('SUCCESS', `Uploaded ${files.length} beep audio files`);
|
|
res.json({ success: true, message: `${files.length} beep audio files uploaded` });
|
|
});
|
|
|
|
app.post('/api/upload/music-audio', upload.array('musicAudioFiles'), (req, res) => {
|
|
const files = req.files || [];
|
|
log('SUCCESS', `Uploaded ${files.length} music audio files`);
|
|
res.json({ success: true, message: `${files.length} music audio files uploaded` });
|
|
});
|
|
|
|
app.post('/api/upload/voice-audio', upload.array('voiceAudioFiles'), (req, res) => {
|
|
const files = req.files || [];
|
|
log('SUCCESS', `Uploaded ${files.length} voice audio files`);
|
|
res.json({ success: true, message: `${files.length} voice audio files uploaded` });
|
|
});
|
|
|
|
app.post('/api/upload/line-audio', upload.array('lineAudioFiles'), (req, res) => {
|
|
const files = req.files || [];
|
|
log('SUCCESS', `Uploaded ${files.length} line audio files`);
|
|
res.json({ success: true, message: `${files.length} line audio files uploaded` });
|
|
});
|
|
|
|
app.post('/api/upload/station-audio', upload.array('stationAudioFiles'), (req, res) => {
|
|
const files = req.files || [];
|
|
log('SUCCESS', `Uploaded ${files.length} station audio files`);
|
|
res.json({ success: true, message: `${files.length} station audio files uploaded` });
|
|
});
|
|
|
|
app.get('/api/files', (req, res) => {
|
|
res.json({ files: getManagedFiles() });
|
|
});
|
|
|
|
app.delete('/api/files', (req, res) => {
|
|
const filePath = req.query.path;
|
|
if (!filePath) return res.status(400).json({ success: false, message: 'Path required' });
|
|
|
|
const resolved = resolveManagedPath(filePath);
|
|
if (!resolved) return res.status(403).json({ success: false, message: 'Invalid path' });
|
|
const { fullPath, logicalPath } = resolved;
|
|
|
|
if (fs.existsSync(fullPath)) {
|
|
try {
|
|
fs.unlinkSync(fullPath);
|
|
log('INFO', `Deleted file: ${logicalPath}`);
|
|
|
|
// Also try to delete counterpart (.dfpwm if .wav deleted, or vice versa)
|
|
if (logicalPath.endsWith('.wav')) {
|
|
const dfpwmPath = fullPath.replace(/\.wav$/, '.dfpwm');
|
|
if (fs.existsSync(dfpwmPath)) fs.unlinkSync(dfpwmPath);
|
|
} else if (logicalPath.endsWith('.dfpwm')) {
|
|
const wavPath = fullPath.replace(/\.dfpwm$/, '.wav');
|
|
if (fs.existsSync(wavPath)) fs.unlinkSync(wavPath);
|
|
}
|
|
|
|
res.json({ success: true });
|
|
} catch (e) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
} else {
|
|
res.status(404).json({ success: false, message: 'File not found' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/stations', (req, res) => {
|
|
const list = getMergedStationList()
|
|
.sort((a, b) => String(a.code || '').localeCompare(String(b.code || '')) || String(a.name || '').localeCompare(String(b.name || '')));
|
|
res.json({ success: true, stations: list });
|
|
});
|
|
|
|
app.post('/api/stations', express.json(), (req, res) => {
|
|
const name = String(req.body?.name || '').trim();
|
|
const code = req.body?.code === undefined ? '' : String(req.body.code).trim();
|
|
if (!name) return res.status(400).json({ success: false, message: 'Station name required' });
|
|
if (stations[name]) return res.status(409).json({ success: false, message: 'Station already exists' });
|
|
|
|
stations[name] = {
|
|
name,
|
|
code,
|
|
platform: getPlatformNumberFromName(name),
|
|
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) {
|
|
proxyUrl = 'http://127.0.0.1:7890';
|
|
}
|
|
|
|
const requestBody = JSON.stringify({
|
|
text: text,
|
|
reference_id: reference_id,
|
|
model: model,
|
|
format: "wav"
|
|
});
|
|
|
|
const options = {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649',
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(requestBody)
|
|
},
|
|
body: requestBody
|
|
};
|
|
|
|
const response = proxyUrl
|
|
? await requestViaProxy('https://api.fish.audio/v1/tts', options, proxyUrl)
|
|
: await requestDirect('https://api.fish.audio/v1/tts', options);
|
|
|
|
if (!response.ok) {
|
|
const msg = (response.text && String(response.text).trim().length > 0)
|
|
? response.text
|
|
: `Upstream API error (HTTP ${response.status})`;
|
|
const err = new Error(msg);
|
|
err.status = response.status || 500;
|
|
throw err;
|
|
}
|
|
|
|
const tempName = `temp_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
|
|
const tempPath = path.join(uploadDir, tempName);
|
|
fs.writeFileSync(tempPath, response.buffer);
|
|
|
|
return { tempName, tempPath, url: `/uploads/${tempName}` };
|
|
}
|
|
|
|
app.post('/api/tts/upload_temp', upload.single('audio'), (req, res) => {
|
|
try {
|
|
if (!req.file) return res.status(400).json({ success: false, message: 'No audio file uploaded' });
|
|
|
|
const tempName = `temp_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
|
|
const tempPath = path.join(uploadDir, tempName);
|
|
|
|
fs.renameSync(req.file.path, tempPath);
|
|
|
|
res.json({
|
|
success: true,
|
|
tempName: tempName,
|
|
url: `/uploads/${tempName}`
|
|
});
|
|
} catch (e) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
app.post('/api/tts/generate', express.json(), async (req, res) => {
|
|
const text = String(req.body?.text || '').trim();
|
|
const reference_id = String(req.body?.reference_id || '').trim();
|
|
const model = String(req.body?.model || DEFAULT_TTS_MODEL).trim();
|
|
if (!text || !reference_id) return res.status(400).json({ success: false, message: 'Missing text or reference_id' });
|
|
if (!model) return res.status(400).json({ success: false, message: 'Missing model' });
|
|
|
|
try {
|
|
let proxyUrl = process.env.HTTP_PROXY || process.env.http_proxy || process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy;
|
|
|
|
console.log(`[TTS] Sending request for text: "${text}" with model "${model}"${proxyUrl ? ` via proxy: ${proxyUrl}` : ''}`);
|
|
|
|
const requestBody = JSON.stringify({
|
|
text: text,
|
|
reference_id: reference_id,
|
|
model: model,
|
|
format: "wav"
|
|
});
|
|
|
|
const options = {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649',
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(requestBody)
|
|
},
|
|
body: requestBody
|
|
};
|
|
|
|
// Always try to use proxy
|
|
if (!proxyUrl) {
|
|
proxyUrl = 'http://127.0.0.1:7890';
|
|
}
|
|
|
|
let response;
|
|
try {
|
|
response = proxyUrl
|
|
? await requestViaProxy('https://api.fish.audio/v1/tts', options, proxyUrl)
|
|
: await requestDirect('https://api.fish.audio/v1/tts', options);
|
|
} catch (e) {
|
|
// Fallback if proxy fails
|
|
if (proxyUrl === 'http://127.0.0.1:7890') {
|
|
console.log(`[TTS] Local proxy failed, attempting direct connection...`);
|
|
response = await requestDirect('https://api.fish.audio/v1/tts', options);
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const msg = (response.text && String(response.text).trim().length > 0)
|
|
? response.text
|
|
: `Upstream API error (HTTP ${response.status})`;
|
|
console.error(`[TTS] API Error Status ${response.status}: ${msg}`);
|
|
return res.status(response.status || 500).json({ success: false, message: msg });
|
|
}
|
|
|
|
// Save to a temporary file
|
|
const tempName = `temp_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
|
|
const tempPath = path.join(uploadDir, tempName);
|
|
fs.writeFileSync(tempPath, response.buffer);
|
|
|
|
console.log(`[TTS] Success: saved to ${tempName}`);
|
|
res.json({ success: true, url: `/uploads/${tempName}`, tempName });
|
|
} catch (e) {
|
|
console.error("[TTS] Generation Error:", e);
|
|
const msg = (e && e.message && String(e.message).trim().length > 0)
|
|
? e.message
|
|
: (e && e.code ? String(e.code) : 'Unknown error');
|
|
res.status(500).json({ success: false, message: msg + (e && e.cause && e.cause.message ? ` (${e.cause.message})` : '') });
|
|
}
|
|
});
|
|
|
|
// Endpoint to receive temp files from client
|
|
app.post('/api/tts/upload_temp', upload.fields([{ name: 'zhAudio', maxCount: 1 }, { name: 'enAudio', maxCount: 1 }]), (req, res) => {
|
|
try {
|
|
const zhFile = req.files['zhAudio'] ? req.files['zhAudio'][0] : null;
|
|
const enFile = req.files['enAudio'] ? req.files['enAudio'][0] : null;
|
|
|
|
if (!zhFile || !enFile) {
|
|
return res.status(400).json({ success: false, message: 'Missing audio files' });
|
|
}
|
|
|
|
// Rename to secure temp names
|
|
const zhTempName = `temp_zh_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
|
|
const jaTempName = `temp_ja_${Date.now()}_${Math.floor(Math.random() * 1000)}.wav`;
|
|
|
|
const zhTempPath = path.join(uploadDir, zhTempName);
|
|
const jaTempPath = path.join(uploadDir, jaTempName);
|
|
|
|
fs.renameSync(zhFile.path, zhTempPath);
|
|
fs.renameSync(enFile.path, jaTempPath);
|
|
|
|
res.json({
|
|
success: true,
|
|
zhTempName,
|
|
jaTempName,
|
|
zhUrl: `/uploads/${zhTempName}`,
|
|
jaUrl: `/uploads/${jaTempName}`,
|
|
// Keep old keys temporarily so cached frontends do not break.
|
|
enTempName: jaTempName,
|
|
enUrl: `/uploads/${jaTempName}`
|
|
});
|
|
} catch (e) {
|
|
res.status(500).json({ success: false, message: e.message });
|
|
}
|
|
});
|
|
|
|
// 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' ? audioLineDir : 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: `/media/temp/${path.basename(built.wavPath)}`,
|
|
durationSec: built.durationSec
|
|
});
|
|
} catch (e) {
|
|
res.status(500).json({ success: false, message: e?.message || String(e) });
|
|
}
|
|
});
|
|
|
|
app.post('/api/temp-broadcast/compose', express.json(), async (req, res) => {
|
|
const text = String(req.body?.text || '').trim();
|
|
const reference_id = String(req.body?.reference_id || DEFAULT_ZH_REFERENCE_ID).trim();
|
|
const model = String(req.body?.model || DEFAULT_TTS_MODEL).trim();
|
|
const prefixName = String(req.body?.prefixName || 'lsts').trim();
|
|
if (!text) return res.status(400).json({ success: false, message: 'text required' });
|
|
if (!reference_id) return res.status(400).json({ success: false, message: 'reference_id required' });
|
|
if (!model) return res.status(400).json({ success: false, message: 'model required' });
|
|
|
|
const prefixWavPath = findWavByBaseName(prefixName);
|
|
if (!prefixWavPath) return res.status(404).json({ success: false, message: `Prefix wav not found: ${prefixName}.wav` });
|
|
|
|
let ttsTempPath = null;
|
|
try {
|
|
const tts = await generateTtsWavToTemp(text, reference_id, model);
|
|
ttsTempPath = tts.tempPath;
|
|
const built = buildTempBroadcastFromWav(prefixWavPath, ttsTempPath);
|
|
try { fs.unlinkSync(ttsTempPath); } catch {}
|
|
ttsTempPath = null;
|
|
|
|
res.json({
|
|
success: true,
|
|
broadcastId: built.broadcastId,
|
|
wavUrl: `/media/temp/${path.basename(built.wavPath)}`,
|
|
durationSec: built.durationSec
|
|
});
|
|
} catch (e) {
|
|
if (ttsTempPath) {
|
|
try { fs.unlinkSync(ttsTempPath); } catch {}
|
|
}
|
|
res.status(Number(e?.status) || 500).json({ success: false, message: e?.message || String(e) });
|
|
}
|
|
});
|
|
|
|
app.post('/api/temp-broadcast/publish', (req, res) => {
|
|
const broadcastId = String(req.body?.broadcastId || '').trim();
|
|
const urgency = String(req.body?.urgency || 'normal').trim().toLowerCase();
|
|
const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : [];
|
|
|
|
if (!broadcastId) return res.status(400).json({ success: false, message: 'broadcastId required' });
|
|
if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' });
|
|
if (urgency !== 'normal' && urgency !== 'emergency') return res.status(400).json({ success: false, message: 'Invalid urgency' });
|
|
|
|
const wavPath = path.join(tempBroadcastDir, `temp_${broadcastId}.wav`);
|
|
if (!fs.existsSync(wavPath)) return res.status(404).json({ success: false, message: 'Temp broadcast not found (wav)' });
|
|
|
|
const wavBuf = fs.readFileSync(wavPath);
|
|
const durationSec = getWavDurationSec(wavBuf) || 0;
|
|
const streamUrl = getManagedMediaUrl('temp', path.basename(wavPath));
|
|
const priority = urgency === 'emergency' ? 2 : 0;
|
|
const interruptMode = urgency === 'emergency' ? 'all' : 'none';
|
|
const source = urgency === 'emergency' ? 'temp_emergency' : 'temp_normal';
|
|
const requeueOnPreempt = urgency !== 'emergency';
|
|
|
|
let sent = 0;
|
|
let failed = 0;
|
|
const results = [];
|
|
|
|
for (const group of expandStationTargets(stationsReq)) {
|
|
const onlineMembers = group.memberNames.filter(memberName => {
|
|
const ws = getStationWs(memberName);
|
|
return !!ws && ws.readyState === WebSocket.OPEN;
|
|
});
|
|
|
|
if (onlineMembers.length === 0) {
|
|
failed++;
|
|
results.push({ stationName: group.name, ok: false, reason: 'offline', memberCount: group.memberCount });
|
|
continue;
|
|
}
|
|
|
|
for (let idx = 0; idx < onlineMembers.length; idx++) {
|
|
const memberName = onlineMembers[idx];
|
|
const ws = getStationWs(memberName);
|
|
enqueueStationAudioTask(memberName, ws, {
|
|
audioFormat: 'wav-stream-live',
|
|
sourcePath: wavPath,
|
|
streamUrl,
|
|
durationSec,
|
|
priority,
|
|
interruptMode,
|
|
source,
|
|
audioId: `temp-${broadcastId}-${memberName}-${Date.now()}`,
|
|
requeueOnPreempt,
|
|
consoleStreamStart: idx === 0 ? { type: 'stream_start', station: group.name, seq: [`TEMP:${broadcastId}`], streamName: getStationStreamName(memberName), hlsUrl: getStationHlsUrl(getStationStreamName(memberName)) } : null,
|
|
consoleStreamEnd: idx === 0 ? { type: 'stream_end', station: group.name } : null
|
|
});
|
|
}
|
|
sent++;
|
|
results.push({ stationName: group.name, ok: true, memberCount: onlineMembers.length, totalMembers: group.memberCount });
|
|
}
|
|
|
|
res.json({ success: true, sent, failed, results });
|
|
});
|
|
|
|
app.get('/api/music/tracks', (req, res) => {
|
|
res.json({ success: true, tracks: listMusicTracks() });
|
|
});
|
|
|
|
app.post('/api/music/start', express.json(), (req, res) => {
|
|
const stationsReq = Array.isArray(req.body?.stations) ? req.body.stations : [];
|
|
const track = normalizeBaseName(req.body?.track);
|
|
const loop = Boolean(req.body?.loop);
|
|
|
|
if (!track) return res.status(400).json({ success: false, message: 'track required' });
|
|
if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' });
|
|
|
|
let playbackAudio;
|
|
try {
|
|
playbackAudio = getPlaybackAudioByBaseName(track);
|
|
} catch (e) {
|
|
return res.status(404).json({ success: false, message: e?.message || String(e) });
|
|
}
|
|
|
|
let sent = 0;
|
|
let failed = 0;
|
|
const results = [];
|
|
|
|
for (const group of expandStationTargets(stationsReq)) {
|
|
const onlineMembers = group.memberNames.filter(memberName => {
|
|
const ws = getStationWs(memberName);
|
|
return !!ws && ws.readyState === WebSocket.OPEN;
|
|
});
|
|
|
|
if (onlineMembers.length === 0) {
|
|
failed++;
|
|
results.push({ stationName: group.name, ok: false, reason: 'offline', memberCount: group.memberCount });
|
|
continue;
|
|
}
|
|
|
|
for (let idx = 0; idx < onlineMembers.length; idx++) {
|
|
const memberName = onlineMembers[idx];
|
|
const ws = getStationWs(memberName);
|
|
stopStationMusic(memberName, ws);
|
|
stationMusicStates.set(memberName, { enabled: true, track, loop });
|
|
|
|
const streamName = getStationStreamName(memberName);
|
|
const hlsUrl = HLS_OUTPUT_MODE === 'local'
|
|
? getLocalHlsUrlForRequest(streamName, req)
|
|
: getStationHlsUrlForRequest(streamName, req);
|
|
|
|
const enqueueOnce = () => {
|
|
const currentWs = getStationWs(memberName);
|
|
if (!currentWs || currentWs.readyState !== WebSocket.OPEN) {
|
|
setTimeout(enqueueOnce, 5000);
|
|
return;
|
|
}
|
|
|
|
if (playbackAudio.sourcePath) {
|
|
enqueueStationAudioTask(memberName, currentWs, {
|
|
audioFormat: 'wav-stream-live',
|
|
sourcePath: playbackAudio.sourcePath,
|
|
durationSec: playbackAudio.durationSec,
|
|
priority: -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}`);
|
|
});
|