diff --git a/CloudServer/server.js b/CloudServer/server.js index 54e99df..3d69875 100644 --- a/CloudServer/server.js +++ b/CloudServer/server.js @@ -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, diff --git a/station_broadcast.lua b/station_broadcast.lua index 41cd60d..28ed290 100644 --- a/station_broadcast.lua +++ b/station_broadcast.lua @@ -1,6 +1,6 @@ -- 1. Configuration & Constants -local APP_VERSION_TEXT = "v1.0.114" +local APP_VERSION_TEXT = "v1.0.115" local CONFIG = { CLOUD_URL = "ws://stabadmin.fse-media.group",