fix(audio,schedule): 音乐只扫 audio/music 避免错播短提示音假失败 + 远端主机完全离线时通过 HTTP REST + WS 主动推送拿到时刻表
- listMusicTracks/getPlaybackAudioByBaseName 彻底去掉 audio/beep fallback:空目录就空,提示音 wav(fc1/ksts) 不再出现在音乐列表,杜绝 API 返回 sent=1 但实际播放 0.25s 导致用户以为失败 - music enqueue 优先级从 0 下调到 -1,approaching/alert/arrival 永远干净抢占,equal-prio 不会把整首歌切成碎段 - 新增 4 套取时刻表的兜底方法(最远端主机完全加载不到也能用): 1. POST/GET /api/stations/:name/schedule — 管理员或远端屏幕 HTTP 拉/手动注入 upcomingTrains+currentTrain,完全不依赖本地 Lua block-reader 2. GET /api/stations/schedule(?line=CE) — 批量按线路拉取全量时刻表摘要 3. 同站点 schedule TTL 10min 鲜度字段 scheduleIsStale / scheduleFreshForMs 返回给客户端自行判断 4. 每次 heartbeat/station_schedule_sync 触发 WS 广播 station_schedule_available + same_cable_group_schedule 推送到同站/同线缆分组全部连接(不只 master) - normalizeStationRecord + POST schedule 新站自动补 lastScheduleSyncedAt=0 避免 500
This commit is contained in:
+253
-46
@@ -450,6 +450,7 @@ function upsertStationNode(stationName, ws, data) {
|
||||
st.upcomingTrains = bestSyncedNode.upcomingTrains || [];
|
||||
st.upcomingCount = bestSyncedNode.upcomingCount || 0;
|
||||
st.currentTrain = bestSyncedNode.currentTrain || null;
|
||||
st.lastScheduleSyncedAt = bestSynced;
|
||||
}
|
||||
|
||||
normalizeStationRecord(st);
|
||||
@@ -1388,13 +1389,9 @@ function getManagedMediaUrl(kind, 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;
|
||||
const addTracksFromDir = (dir) => {
|
||||
if (!dir || !fs.existsSync(dir)) return;
|
||||
for (const file of fs.readdirSync(dir)) {
|
||||
if (!file || file.startsWith('.')) continue;
|
||||
const full = path.join(dir, file);
|
||||
@@ -1403,8 +1400,13 @@ function listMusicTracks() {
|
||||
const base = normalizeBaseName(file);
|
||||
if (base) out.add(base);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
addTracksFromDir(audioMusicDir);
|
||||
// NOTE: DO NOT fallback to beep/station/line dirs — those are short ~1s
|
||||
// announcement stingers. Users see "fc1/ksts" in music list, select them,
|
||||
// the API returns success -> plays 0.25s of stinger -> user thinks music
|
||||
// player is broken / didn't play at all. Music list = only music dir tracks.
|
||||
return Array.from(out).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
@@ -1451,48 +1453,39 @@ function resolveAudioPathWithFuzzy(dir, base, ext) {
|
||||
function getPlaybackAudioByBaseName(baseName) {
|
||||
const base = normalizeBaseName(baseName);
|
||||
if (!base) throw new Error('Invalid track name');
|
||||
const SEARCH_KINDS = STREAMABLE_AUDIO_EXTENSIONS;
|
||||
|
||||
for (const ext of STREAMABLE_AUDIO_EXTENSIONS) {
|
||||
const searchDirs = [
|
||||
{ dir: audioMusicDir, kind: 'audio/music' },
|
||||
...getBeepAudioDirs().map(d => ({ dir: d, kind: 'audio/beep' })),
|
||||
{ dir: audioStationDir, kind: 'audio/station' },
|
||||
{ dir: audioLineDir, kind: 'audio/station' }
|
||||
];
|
||||
for (const { dir, kind } of searchDirs) {
|
||||
const p = resolveAudioPathWithFuzzy(dir, base, ext);
|
||||
if (!p) continue;
|
||||
let durationSec = 0;
|
||||
if (ext === '.wav') {
|
||||
const buffer = fs.readFileSync(p);
|
||||
durationSec = getWavDurationSec(buffer) || 0;
|
||||
}
|
||||
return {
|
||||
sourcePath: p,
|
||||
audioFormat: 'stream',
|
||||
streamUrl: getManagedMediaUrl(kind, path.basename(p)),
|
||||
durationSec
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const { dir } of [
|
||||
{ dir: audioMusicDir },
|
||||
...getBeepAudioDirs().map(d => ({ dir: d })),
|
||||
{ dir: audioStationDir },
|
||||
{ dir: audioLineDir }
|
||||
]) {
|
||||
const p = resolveAudioPathWithFuzzy(dir, base, '.dfpwm');
|
||||
// Music tracks only live in audio/music. Users historically got confused when
|
||||
// music list showed station beeps (fc1/ksts via empty-dir fallback), clicked them,
|
||||
// and received a successful HTTP response that actually played a 0.25s stinger —
|
||||
// the UX looked like "music failed". Force music tracks to resolve only inside
|
||||
// audioMusicDir; missing songs → 404 explicitly instead of silent fallback to
|
||||
// audio/beep or audio/station.
|
||||
const kindTag = 'audio/music';
|
||||
for (const ext of SEARCH_KINDS) {
|
||||
const p = resolveAudioPathWithFuzzy(audioMusicDir, base, ext);
|
||||
if (!p) continue;
|
||||
const buffer = fs.readFileSync(p);
|
||||
let durationSec = 0;
|
||||
if (ext === '.wav') {
|
||||
const buffer = fs.readFileSync(p);
|
||||
durationSec = getWavDurationSec(buffer) || 0;
|
||||
}
|
||||
return {
|
||||
buffer,
|
||||
audioFormat: 'dfpwm',
|
||||
durationSec: buffer.length / 6000
|
||||
sourcePath: p,
|
||||
audioFormat: 'stream',
|
||||
streamUrl: getManagedMediaUrl(kindTag, path.basename(p)),
|
||||
durationSec
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Audio not found: ${base}`);
|
||||
// dfpwm fallback only inside audio/music too
|
||||
const pd = resolveAudioPathWithFuzzy(audioMusicDir, base, '.dfpwm');
|
||||
if (pd) {
|
||||
const buffer = fs.readFileSync(pd);
|
||||
return { buffer, audioFormat: 'dfpwm', durationSec: buffer.length / 6000 };
|
||||
}
|
||||
|
||||
throw new Error(`Music track not found in audio/music/: ${base} (upload .wav files to audio/music/ directory)`);
|
||||
}
|
||||
|
||||
function cancelStationAudioTask(task) {
|
||||
@@ -1940,6 +1933,7 @@ 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 || '';
|
||||
if (station.lastScheduleSyncedAt === undefined) station.lastScheduleSyncedAt = 0;
|
||||
}
|
||||
|
||||
function refreshStationIndex() {
|
||||
@@ -2800,6 +2794,10 @@ function handleHeartbeat(ws, data) {
|
||||
sendStationGroupState(cleanName);
|
||||
broadcastUpdate();
|
||||
}
|
||||
// If heartbeat brought in new schedule data, notify every connected node
|
||||
if (data.upcomingTrains || data.currentTrain) {
|
||||
try { broadcastStationScheduleToEveryNode(cleanName); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function handleScheduleSync(ws, data) {
|
||||
@@ -2815,6 +2813,7 @@ function handleScheduleSync(ws, data) {
|
||||
}
|
||||
sendStationGroupState(cleanName);
|
||||
broadcastUpdate();
|
||||
try { broadcastStationScheduleToEveryNode(cleanName); } catch {}
|
||||
}
|
||||
|
||||
function handleStationRegister(ws, data) {
|
||||
@@ -3574,7 +3573,8 @@ app.post('/api/stations', express.json(), (req, res) => {
|
||||
upcomingCount: 0,
|
||||
upcomingTrains: [],
|
||||
currentTrain: null,
|
||||
nodes: []
|
||||
nodes: [],
|
||||
lastScheduleSyncedAt: 0
|
||||
};
|
||||
normalizeStationRecord(stations[name]);
|
||||
saveData();
|
||||
@@ -3863,6 +3863,213 @@ app.delete('/api/trains/:id', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// =================== Schedule APIs (offline-station / remote-host fallback) ===================
|
||||
//
|
||||
// Problem statement the user reported:
|
||||
// "The station at the FAR END of a long cable may not load (e.g. because
|
||||
// cable middle node died / cable line is weak). If a train parks exactly
|
||||
// at that far-end position, the station client can't fetch the schedule
|
||||
// from the local Lua-side list.txt/block-reader because that node never
|
||||
// came online. How can we get the schedule remotely / by other means?"
|
||||
//
|
||||
// Solution (cloud-side REST + multi-sync aggregation):
|
||||
// [Level 1] Per-station schedule aggregation: ANY node of station X that
|
||||
// loads list.txt / runs a block-reader → calls
|
||||
// WS type=station_schedule_sync (already in handleScheduleSync)
|
||||
// or WS heartbeat with upcomingTrains/currentTrain (already merged
|
||||
// into stations[X].upcomingTrains via bestSynced node).
|
||||
// [Level 2] HTTP REST: GET /api/stations/:nameOrCode/schedule is callable
|
||||
// from ANYWHERE on the network (far-end box, admin tool, even a
|
||||
// second display that's running pure browser without Lua loaded).
|
||||
// Returns upcomingTrains, currentTrain, activeTrains for station,
|
||||
// with TTL, and tells caller the "schedule freshness" so it can
|
||||
// choose to fall back to another station.
|
||||
// [Level 3] Cross-station fallback: if station X has NO synced schedule,
|
||||
// but the *same line code* stations CE-01..CE-14 have schedules,
|
||||
// we merge upcomingTrains with CE line's global activeTrains so
|
||||
// a stopped train at far-end can at least be seen through another
|
||||
// station's data.
|
||||
// [Level 4] Upstream list.txt + block-reader manual upload APIs:
|
||||
// POST /api/stations/:name/schedule lets a remote tool inject
|
||||
// list.txt content / upcomingTrains even if the box never boots.
|
||||
|
||||
function pickScheduleStation(selector) {
|
||||
const key = String(selector || '').trim();
|
||||
if (!key) return null;
|
||||
// 1. exact station name
|
||||
if (stations[key]) return stations[key];
|
||||
// 2. exact station code
|
||||
for (const k of Object.keys(stations)) {
|
||||
const s = stations[k];
|
||||
if (s && String(s.code || '').toLowerCase() === key.toLowerCase()) return s;
|
||||
}
|
||||
// 3. flexible fuzzy name
|
||||
const flex = findStationByFlexibleName(key);
|
||||
return flex || null;
|
||||
}
|
||||
|
||||
function buildSchedulePayload(st, options) {
|
||||
const opts = options || {};
|
||||
const NAME = (st && st.name) || '';
|
||||
const SYNC_TTL_MS = 10 * 60 * 1000; // 10 minutes: schedules older than this are marked as stale
|
||||
|
||||
const now = Date.now();
|
||||
const upcoming = (st && Array.isArray(st.upcomingTrains)) ? st.upcomingTrains.slice() : [];
|
||||
const current = (st && st.currentTrain) ? (typeof st.currentTrain === 'object' ? { ...st.currentTrain } : st.currentTrain) : null;
|
||||
const lastSyncedAt = Number(st && st.lastScheduleSyncedAt) || 0;
|
||||
const freshForMs = lastSyncedAt ? Math.max(0, SYNC_TTL_MS - (now - lastSyncedAt)) : 0;
|
||||
const stale = lastSyncedAt === 0 ? true : (now - lastSyncedAt) >= SYNC_TTL_MS;
|
||||
|
||||
const lineCode = String(st?.code || '').split(/[-_/]/)[0] || '';
|
||||
const onlineWs = getAllStationConnections(NAME).length;
|
||||
|
||||
const sameLineTrains = [];
|
||||
if (opts.includeLineTrains !== false) {
|
||||
for (const t of Array.isArray(activeTrains) ? activeTrains : []) {
|
||||
if (!t) continue;
|
||||
const tLine = String(t?.stationCode || t?.lineCode || '').split(/[-_/]/)[0];
|
||||
if (lineCode && tLine === lineCode) sameLineTrains.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
stationName: NAME,
|
||||
stationCode: st?.code || '',
|
||||
stationPlatform: st?.platform || '',
|
||||
onlineNodeCount: onlineWs,
|
||||
totalNodeCount: Array.isArray(st?.nodes) ? st.nodes.length : 0,
|
||||
cableGroupId: st?.audioCableGroup || '',
|
||||
lastScheduleSyncedAt,
|
||||
lastSeen: Number(st?.lastSeen || 0),
|
||||
scheduleFreshForMs: freshForMs,
|
||||
scheduleIsStale: stale,
|
||||
scheduleTtlMs: SYNC_TTL_MS,
|
||||
upcomingCount: Number(st?.upcomingCount) || upcoming.length,
|
||||
upcomingTrains: upcoming,
|
||||
currentTrain: current,
|
||||
sameLineActiveTrains: sameLineTrains
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/api/stations/:name/schedule', (req, res) => {
|
||||
const rawName = decodeURIComponent(String(req.params.name || '')).trim();
|
||||
const includeLine = req.query.line === '0' ? false : true;
|
||||
const st = pickScheduleStation(rawName);
|
||||
if (!st) {
|
||||
// Even when we can't find the exact station, try giving cross-line data
|
||||
// so a far-end client with a stationName typo / not yet registered
|
||||
// still gets something usable.
|
||||
const codePrefix = rawName.split(/[-_/]/)[0] || '';
|
||||
const lineTrains = [];
|
||||
if (codePrefix) {
|
||||
for (const t of Array.isArray(activeTrains) ? activeTrains : []) {
|
||||
const tLine = String(t?.stationCode || t?.lineCode || '').split(/[-_/]/)[0];
|
||||
if (tLine.toLowerCase() === codePrefix.toLowerCase()) lineTrains.push(t);
|
||||
}
|
||||
}
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: `Station not found: ${rawName}`,
|
||||
hint: `Register via POST /api/stations or call POST /api/stations/${encodeURIComponent(rawName)}/schedule to upload schedule manually`,
|
||||
sameLineActiveTrains: lineTrains
|
||||
});
|
||||
}
|
||||
res.json({ success: true, schedule: buildSchedulePayload(st, { includeLineTrains: includeLine }) });
|
||||
});
|
||||
|
||||
// Convenience: list ALL stations' schedule summary. This is handy when a
|
||||
// far-end admin panel or a secondary sign doesn't know which station code to
|
||||
// pull — it can just GET /api/stations/schedule every ~30s.
|
||||
app.get('/api/stations/schedule', (req, res) => {
|
||||
const linesOnly = req.query.line;
|
||||
const out = {};
|
||||
for (const k of Object.keys(stations)) {
|
||||
const s = stations[k];
|
||||
if (!s) continue;
|
||||
if (linesOnly) {
|
||||
const lineCode = String(s.code || '').split(/[-_/]/)[0];
|
||||
if (String(linesOnly).toLowerCase() !== lineCode.toLowerCase()) continue;
|
||||
}
|
||||
out[k] = buildSchedulePayload(s, { includeLineTrains: false });
|
||||
}
|
||||
res.json({ success: true, schedules: out });
|
||||
});
|
||||
|
||||
// Manual schedule push (for far-end offline stations): lets admin / a
|
||||
// remote script inject upcomingTrains + currentTrain when the Lua host
|
||||
// never boots at the far end. Exactly equivalent to the station box itself
|
||||
// calling WS station_schedule_sync, but via HTTP.
|
||||
app.post('/api/stations/:name/schedule', express.json({ limit: '2mb' }), (req, res) => {
|
||||
const rawName = decodeURIComponent(String(req.params.name || '')).trim();
|
||||
if (!stations[rawName]) {
|
||||
const code = String(req.body?.code || '').trim() || String(req.body?.stationCode || '').trim() || '';
|
||||
stations[rawName] = {
|
||||
name: rawName,
|
||||
code,
|
||||
platform: getPlatformNumberFromName(rawName),
|
||||
audioCableGroup: '',
|
||||
version: '',
|
||||
lastSeen: Date.now(),
|
||||
upcomingCount: 0,
|
||||
upcomingTrains: [],
|
||||
currentTrain: null,
|
||||
nodes: [],
|
||||
lastScheduleSyncedAt: 0
|
||||
};
|
||||
}
|
||||
const st = stations[rawName];
|
||||
const upc = Array.isArray(req.body?.upcomingTrains) ? req.body.upcomingTrains.slice() : (Array.isArray(st.upcomingTrains) ? st.upcomingTrains.slice() : []);
|
||||
const upcCount = Number(req.body?.upcomingCount ?? upc.length);
|
||||
const cur = (req.body?.currentTrain !== undefined) ? req.body.currentTrain : st.currentTrain;
|
||||
st.upcomingTrains = upc;
|
||||
st.upcomingCount = upcCount;
|
||||
st.currentTrain = cur;
|
||||
st.lastScheduleSyncedAt = Date.now();
|
||||
st.lastSeen = Math.max(Number(st.lastSeen || 0), st.lastScheduleSyncedAt);
|
||||
saveData();
|
||||
sendStationGroupState(st.name);
|
||||
broadcastUpdate();
|
||||
log('INFO', `Manual schedule push for "${st.name}": upcoming=${upcCount} hasCurrent=${cur ? 'yes' : 'no'}`);
|
||||
res.json({ success: true, schedule: buildSchedulePayload(st) });
|
||||
});
|
||||
|
||||
// Also push schedule as soon as ANY node syncs, not just to console. Broadcast
|
||||
// station_schedule_available to ALL connected station WS, so far-end boxes
|
||||
// that never loaded their own list.txt can still subscribe via WS and
|
||||
// repaint instantly.
|
||||
function broadcastStationScheduleToEveryNode(stationName) {
|
||||
const st = stations[stationName];
|
||||
if (!st) return;
|
||||
const msg = JSON.stringify({
|
||||
type: 'station_schedule_available',
|
||||
stationName: st.name,
|
||||
stationCode: st.code || '',
|
||||
schedule: buildSchedulePayload(st, { includeLineTrains: true })
|
||||
});
|
||||
for (const conn of getAllStationConnections(stationName)) {
|
||||
try { conn.send(msg); } catch {}
|
||||
}
|
||||
// And the same cable group's other stations receive same-line active trains
|
||||
const cgId = st.audioCableGroup || '';
|
||||
const cg = cgId ? audioCableGroups[cgId] : null;
|
||||
if (cg && Array.isArray(cg.stationNames)) {
|
||||
for (const other of cg.stationNames) {
|
||||
if (other === stationName) continue;
|
||||
const o = stations[other];
|
||||
if (!o) continue;
|
||||
for (const conn of getAllStationConnections(other)) {
|
||||
try {
|
||||
conn.send(JSON.stringify({
|
||||
type: 'same_cable_group_schedule',
|
||||
fromStation: stationName,
|
||||
schedule: buildSchedulePayload(o, { includeLineTrains: true })
|
||||
}));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/api/files/content', (req, res) => {
|
||||
const filePath = req.query.path;
|
||||
if (!filePath) return res.status(400).json({ success: false, message: 'Path required' });
|
||||
@@ -4424,7 +4631,7 @@ app.post('/api/music/start', express.json(), (req, res) => {
|
||||
audioFormat: 'wav-stream-live',
|
||||
sourcePath: playbackAudio.sourcePath,
|
||||
durationSec: playbackAudio.durationSec,
|
||||
priority: 0,
|
||||
priority: -1,
|
||||
interruptMode: 'none',
|
||||
source: 'music',
|
||||
loop,
|
||||
@@ -4441,7 +4648,7 @@ app.post('/api/music/start', express.json(), (req, res) => {
|
||||
buffer: playbackAudio.buffer,
|
||||
streamUrl: playbackAudio.streamUrl,
|
||||
durationSec: playbackAudio.durationSec,
|
||||
priority: 0,
|
||||
priority: -1,
|
||||
interruptMode: 'none',
|
||||
source: 'music',
|
||||
loop,
|
||||
|
||||
Reference in New Issue
Block a user