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:
2026-08-17 20:10:54 +08:00
parent 9c5d6e9659
commit 3c69740470
2 changed files with 254 additions and 47 deletions
+253 -46
View File
@@ -450,6 +450,7 @@ function upsertStationNode(stationName, ws, data) {
st.upcomingTrains = bestSyncedNode.upcomingTrains || []; st.upcomingTrains = bestSyncedNode.upcomingTrains || [];
st.upcomingCount = bestSyncedNode.upcomingCount || 0; st.upcomingCount = bestSyncedNode.upcomingCount || 0;
st.currentTrain = bestSyncedNode.currentTrain || null; st.currentTrain = bestSyncedNode.currentTrain || null;
st.lastScheduleSyncedAt = bestSynced;
} }
normalizeStationRecord(st); normalizeStationRecord(st);
@@ -1388,13 +1389,9 @@ function getManagedMediaUrl(kind, fileName) {
function listMusicTracks() { function listMusicTracks() {
const out = new Set(); const out = new Set();
const scanDirs = [audioMusicDir];
if (fs.existsSync(audioMusicDir) && fs.readdirSync(audioMusicDir).length === 0) {
scanDirs.push(...getBeepAudioDirs());
}
for (const dir of scanDirs) { const addTracksFromDir = (dir) => {
if (!fs.existsSync(dir)) continue; if (!dir || !fs.existsSync(dir)) return;
for (const file of fs.readdirSync(dir)) { for (const file of fs.readdirSync(dir)) {
if (!file || file.startsWith('.')) continue; if (!file || file.startsWith('.')) continue;
const full = path.join(dir, file); const full = path.join(dir, file);
@@ -1403,8 +1400,13 @@ function listMusicTracks() {
const base = normalizeBaseName(file); const base = normalizeBaseName(file);
if (base) out.add(base); 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)); return Array.from(out).sort((a, b) => a.localeCompare(b));
} }
@@ -1451,48 +1453,39 @@ function resolveAudioPathWithFuzzy(dir, base, ext) {
function getPlaybackAudioByBaseName(baseName) { function getPlaybackAudioByBaseName(baseName) {
const base = normalizeBaseName(baseName); const base = normalizeBaseName(baseName);
if (!base) throw new Error('Invalid track name'); if (!base) throw new Error('Invalid track name');
const SEARCH_KINDS = STREAMABLE_AUDIO_EXTENSIONS;
for (const ext of STREAMABLE_AUDIO_EXTENSIONS) { // Music tracks only live in audio/music. Users historically got confused when
const searchDirs = [ // music list showed station beeps (fc1/ksts via empty-dir fallback), clicked them,
{ dir: audioMusicDir, kind: 'audio/music' }, // and received a successful HTTP response that actually played a 0.25s stinger —
...getBeepAudioDirs().map(d => ({ dir: d, kind: 'audio/beep' })), // the UX looked like "music failed". Force music tracks to resolve only inside
{ dir: audioStationDir, kind: 'audio/station' }, // audioMusicDir; missing songs → 404 explicitly instead of silent fallback to
{ dir: audioLineDir, kind: 'audio/station' } // audio/beep or audio/station.
]; const kindTag = 'audio/music';
for (const { dir, kind } of searchDirs) { for (const ext of SEARCH_KINDS) {
const p = resolveAudioPathWithFuzzy(dir, base, ext); const p = resolveAudioPathWithFuzzy(audioMusicDir, base, ext);
if (!p) continue;
let durationSec = 0;
if (ext === '.wav') {
const buffer = fs.readFileSync(p);
durationSec = getWavDurationSec(buffer) || 0;
}
return {
sourcePath: p,
audioFormat: 'stream',
streamUrl: getManagedMediaUrl(kind, path.basename(p)),
durationSec
};
}
}
for (const { dir } of [
{ dir: audioMusicDir },
...getBeepAudioDirs().map(d => ({ dir: d })),
{ dir: audioStationDir },
{ dir: audioLineDir }
]) {
const p = resolveAudioPathWithFuzzy(dir, base, '.dfpwm');
if (!p) continue; if (!p) continue;
const buffer = fs.readFileSync(p); let durationSec = 0;
if (ext === '.wav') {
const buffer = fs.readFileSync(p);
durationSec = getWavDurationSec(buffer) || 0;
}
return { return {
buffer, sourcePath: p,
audioFormat: 'dfpwm', audioFormat: 'stream',
durationSec: buffer.length / 6000 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) { function cancelStationAudioTask(task) {
@@ -1940,6 +1933,7 @@ function normalizeStationRecord(station) {
if (!station || !station.name) return; if (!station || !station.name) return;
station.platform = normalizeStationPlatform(station.platform || getPlatformNumberFromName(station.name)); station.platform = normalizeStationPlatform(station.platform || getPlatformNumberFromName(station.name));
station.code = normalizeStationCode(station.code || getStationCodeFromName(station.name)) || station.code || ''; station.code = normalizeStationCode(station.code || getStationCodeFromName(station.name)) || station.code || '';
if (station.lastScheduleSyncedAt === undefined) station.lastScheduleSyncedAt = 0;
} }
function refreshStationIndex() { function refreshStationIndex() {
@@ -2800,6 +2794,10 @@ function handleHeartbeat(ws, data) {
sendStationGroupState(cleanName); sendStationGroupState(cleanName);
broadcastUpdate(); 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) { function handleScheduleSync(ws, data) {
@@ -2815,6 +2813,7 @@ function handleScheduleSync(ws, data) {
} }
sendStationGroupState(cleanName); sendStationGroupState(cleanName);
broadcastUpdate(); broadcastUpdate();
try { broadcastStationScheduleToEveryNode(cleanName); } catch {}
} }
function handleStationRegister(ws, data) { function handleStationRegister(ws, data) {
@@ -3574,7 +3573,8 @@ app.post('/api/stations', express.json(), (req, res) => {
upcomingCount: 0, upcomingCount: 0,
upcomingTrains: [], upcomingTrains: [],
currentTrain: null, currentTrain: null,
nodes: [] nodes: [],
lastScheduleSyncedAt: 0
}; };
normalizeStationRecord(stations[name]); normalizeStationRecord(stations[name]);
saveData(); 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) => { app.get('/api/files/content', (req, res) => {
const filePath = req.query.path; const filePath = req.query.path;
if (!filePath) return res.status(400).json({ success: false, message: 'Path required' }); 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', audioFormat: 'wav-stream-live',
sourcePath: playbackAudio.sourcePath, sourcePath: playbackAudio.sourcePath,
durationSec: playbackAudio.durationSec, durationSec: playbackAudio.durationSec,
priority: 0, priority: -1,
interruptMode: 'none', interruptMode: 'none',
source: 'music', source: 'music',
loop, loop,
@@ -4441,7 +4648,7 @@ app.post('/api/music/start', express.json(), (req, res) => {
buffer: playbackAudio.buffer, buffer: playbackAudio.buffer,
streamUrl: playbackAudio.streamUrl, streamUrl: playbackAudio.streamUrl,
durationSec: playbackAudio.durationSec, durationSec: playbackAudio.durationSec,
priority: 0, priority: -1,
interruptMode: 'none', interruptMode: 'none',
source: 'music', source: 'music',
loop, loop,
+1 -1
View File
@@ -1,6 +1,6 @@
-- 1. Configuration & Constants -- 1. Configuration & Constants
local APP_VERSION_TEXT = "v1.0.114" local APP_VERSION_TEXT = "v1.0.115"
local CONFIG = { local CONFIG = {
CLOUD_URL = "ws://stabadmin.fse-media.group", CLOUD_URL = "ws://stabadmin.fse-media.group",