feat: add station auto update publishing

Publish the latest station script from the console so online hosts download and reboot immediately while offline hosts update on their next load.
This commit is contained in:
2026-08-02 20:04:55 +08:00
parent 52a643a868
commit 10d15dfddc
5 changed files with 383 additions and 21 deletions
+138 -4
View File
@@ -49,6 +49,8 @@ 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 STATION_SCRIPT_DOWNLOAD_PATH = '/downloads/station_broadcast.lua';
const STREAMABLE_AUDIO_EXTENSIONS = ['.wav', '.mp3', '.ogg', '.flac', '.m4a', '.aac', '.opus'];
const MUSIC_AUDIO_EXTENSIONS = [...STREAMABLE_AUDIO_EXTENSIONS, '.dfpwm'];
@@ -190,6 +192,11 @@ 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');
@@ -199,6 +206,94 @@ 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';
@@ -281,6 +376,7 @@ function getStationGroups() {
code,
platform,
lastSeen: st.lastSeen || 0,
version: st.version || '',
source: 'cc',
tags: ['CC'],
memberNames: [st.name],
@@ -295,6 +391,9 @@ function getStationGroups() {
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;
}
}
@@ -825,6 +924,13 @@ function loadData() {
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) {
@@ -846,7 +952,8 @@ function saveData() {
stations: stationsToSave,
activeTrains,
audioVersion,
versions
versions,
stationUpdate
};
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
} catch (e) {
@@ -1672,9 +1779,10 @@ function handleMessage(ws, data) {
// Send initial state
ws.send(JSON.stringify({
type: 'init_state',
stations,
stations: getMergedStationList(),
activeTrains,
logs: stationLogs
logs: stationLogs,
stationUpdate: getStationUpdateSummary()
}));
return;
}
@@ -1733,6 +1841,7 @@ function handleHeartbeat(ws, data) {
};
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();
@@ -1745,6 +1854,7 @@ function handleHeartbeat(ws, data) {
broadcastUpdate();
}
normalizeStationRecord(stations[cleanName]);
maybeSendStationUpdate(ws, cleanName, data.stationVersion || stations[cleanName].version);
}
}
}
@@ -1771,6 +1881,7 @@ function handleStationRegister(ws, data) {
stationName: cleanName
}));
maybeSendStationUpdate(ws, cleanName, stationVersion);
saveData();
broadcastUpdate();
}
@@ -2066,11 +2177,19 @@ function broadcastUpdate() {
const state = {
type: 'state_update',
stations: list,
activeTrains
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('/api/streams', (req, res) => {
const streams = [];
const seen = new Set();
@@ -2193,6 +2312,21 @@ 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();