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
+63 -13
View File
@@ -929,13 +929,16 @@
<!-- Sidebar: Status -->
<div class="sidebar" style="min-width: 200px; display:flex; flex-direction:column; height:100%;">
<!-- Version Control (Hidden since we don't use it anymore) -->
<div class="version-panel" style="display:none;">
<!-- Station Update -->
<div class="version-panel">
<div class="version-panel-header">
<span>Audio Version</span>
<button class="btn-icon" onclick="fetchVersion()" title="Refresh Version">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
</button>
<span>Station Update</span>
<div style="display:flex; gap:8px;">
<button class="btn" id="publish-update-btn" onclick="publishStationUpdate()" title="Publish Station Update">发布更新</button>
<button class="btn-icon" onclick="fetchStationUpdate()" title="Refresh Update Status">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
</button>
</div>
</div>
<div class="version-display" id="current-version">Loading...</div>
</div>
@@ -1072,6 +1075,7 @@
let currentPath = 'root';
let stationCodeMap = {};
let broadcastingStations = new Set();
let currentStationUpdate = null;
// --- WebSocket ---
const connStatus = document.getElementById('conn-status');
@@ -1118,6 +1122,11 @@
// --- Renderers ---
function updateState(state) {
if (state.stationUpdate) {
currentStationUpdate = state.stationUpdate;
renderStationUpdate(state.stationUpdate);
}
if (state.stations) {
const list = document.getElementById('station-list');
list.innerHTML = '';
@@ -1136,6 +1145,12 @@
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
const stationTarget = st.target || st.name;
const memberCount = Number(st.memberCount || 1);
const stationVersion = st.version || '未知版本';
const targetVersion = currentStationUpdate?.targetVersion || '';
const needsUpdate = !!targetVersion && stationVersion !== targetVersion;
const updateBadge = needsUpdate
? `<span style="font-size:0.65rem; background:rgba(245, 158, 11, 0.12); color:#fbbf24; padding:1px 5px; border-radius:4px; margin-left:6px; font-weight:normal; border:1px solid rgba(245, 158, 11, 0.25)">待更新 ${targetVersion}</span>`
: '';
const div = document.createElement('div');
div.className = 'status-item';
div.id = `station-item-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}`;
@@ -1145,8 +1160,9 @@
<div class="item-title">
${st.name}
${hasCC ? '<span style="font-size:0.65rem; background:rgba(16, 185, 129, 0.1); color:#34d399; padding:1px 5px; border-radius:4px; margin-left:6px; font-weight:normal; border:1px solid rgba(16, 185, 129, 0.2)">CC</span>' : ''}
${updateBadge}
</div>
<div class="item-sub">${st.code}${st.platform ? ` - Platform ${st.platform}` : ''}${memberCount > 1 ? ` · Nodes ${memberCount}` : ''}</div>
<div class="item-sub">${st.code}${st.platform ? ` - Platform ${st.platform}` : ''}${memberCount > 1 ? ` · Nodes ${memberCount}` : ''} · ${stationVersion}</div>
</div>
<div style="display:flex;align-items:center;gap:10px">
<div class="status-dot ${dotClass}" id="station-dot-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}" data-online="${isOnline}" data-name="${st.name}"></div>
@@ -1688,20 +1704,54 @@
// Init
refreshFiles();
fetchVersion();
fetchStationUpdate();
async function fetchVersion() {
function renderStationUpdate(info) {
const el = document.getElementById('current-version');
const localVersion = info?.localVersion || '未知';
const targetVersion = info?.targetVersion || '未发布';
const publishedAt = info?.publishedAt ? new Date(info.publishedAt).toLocaleString() : '未发布';
el.innerHTML = `
<div>服务器脚本:${localVersion}</div>
<div>已发布版本:${targetVersion}</div>
<div style="margin-top:4px;color:var(--text-muted)">发布时间:${publishedAt}</div>
`;
}
async function fetchStationUpdate() {
try {
const res = await fetch('/api/version');
const res = await fetch('/api/station-update');
const data = await res.json();
if (data.version) {
document.getElementById('current-version').textContent = data.version;
}
currentStationUpdate = data;
renderStationUpdate(data);
} catch (e) {
document.getElementById('current-version').textContent = 'Error';
}
}
async function publishStationUpdate() {
if (!confirm('发布后,所有在线主机会立即下载最新 startup 并自动重启;离线主机会在下次加载时立即更新。确定继续吗?')) return;
const btn = document.getElementById('publish-update-btn');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = '发布中...';
try {
const res = await fetch('/api/station-updates/publish', { method: 'POST' });
const data = await res.json();
if (!data.success) throw new Error(data.message || 'Publish failed');
currentStationUpdate = data;
renderStationUpdate(data);
showToast('更新已发布', `目标版本 ${data.targetVersion || data.localVersion},已通知 ${data.notifiedStations || 0}/${data.onlineStations || 0} 台在线主机。`);
} catch (e) {
alert(`发布失败:${e.message || e}`);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
// --- Resizers ---
const resizerV = document.getElementById('resizer-v');
const resizerH = document.getElementById('resizer-h');
+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();
+1 -1
View File
@@ -1,4 +1,4 @@
local STATION_BROADCAST_URL = "https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/branch/main/station_broadcast.lua"
local STATION_BROADCAST_URL = "http://stabadmin.fse-media.group/downloads/station_broadcast.lua"
local function fetch(url)
if not http then
+180 -2
View File
@@ -2,11 +2,12 @@
-- ============================================================================
-- 1. Configuration & Constants
-- ============================================================================
local APP_VERSION_TEXT = "v1.0.44"
local APP_VERSION_TEXT = "v1.0.45"
local CONFIG = {
CLOUD_URL = "ws://stabadmin.fse-media.group",
API_URL = "http://stabadmin.fse-media.group",
STARTUP_PATH = "/startup",
SYNC_INTERVAL = 30,
AUDIO_BASE = "Audio/",
AUDIO_STATION = "Audio/Station/",
@@ -55,7 +56,8 @@ local State = {
currentPlaying = nil,
currentPriority = -1,
interruptSeq = 0,
tickRate = 20
tickRate = 20,
updateInProgress = false
}
-- ============================================================================
@@ -1813,6 +1815,162 @@ function Utils.getStationAudio(code, langPrefix)
return langPrefix .. code
end
local AutoUpdate = {}
function AutoUpdate.fetch(url)
if not http then
return nil, "HTTP API unavailable"
end
if http.checkURL then
local ok, err = http.checkURL(url)
if not ok then
return nil, err or "URL blocked"
end
end
local res, err = http.get(url)
if not res then
return nil, err or "request failed"
end
local body = res.readAll()
res.close()
if type(body) ~= "string" or #body == 0 then
return nil, "empty response"
end
return body
end
function AutoUpdate.writeFile(path, content)
local dir = fs.getDir(path)
if dir and dir ~= "" and not fs.exists(dir) then
fs.makeDir(dir)
end
local handle = fs.open(path, "w")
if not handle then
return nil, "cannot open " .. path
end
handle.write(content)
handle.close()
return true
end
function AutoUpdate.getStartupPath()
if shell and shell.resolve then
return shell.resolve(CONFIG.STARTUP_PATH)
end
return CONFIG.STARTUP_PATH
end
function AutoUpdate.getDownloadUrl(info)
if type(info) == "table" then
local direct = Utils.trim(info.downloadUrl)
if direct ~= "" then
return direct
end
local path = Utils.trim(info.downloadPath)
if path ~= "" then
return CONFIG.API_URL .. path
end
end
return CONFIG.API_URL .. "/downloads/station_broadcast.lua"
end
function AutoUpdate.fetchInfo()
local body, err = AutoUpdate.fetch(CONFIG.API_URL .. "/api/station-update")
if not body then
return nil, err
end
local ok, data = pcall(textutils.unserializeJSON, body)
if not ok or type(data) ~= "table" then
return nil, "invalid update response"
end
return data
end
function AutoUpdate.getTargetVersion(info)
if type(info) ~= "table" then
return ""
end
return Utils.trim(info.targetVersion or info.version)
end
function AutoUpdate.needsUpdate(info)
local targetVersion = AutoUpdate.getTargetVersion(info)
return targetVersion ~= "" and targetVersion ~= APP_VERSION_TEXT
end
function AutoUpdate.apply(info, reason)
if State.updateInProgress then
return false, "update already in progress"
end
local targetVersion = AutoUpdate.getTargetVersion(info)
if targetVersion == "" then
return false, "missing target version"
end
local downloadUrl = AutoUpdate.getDownloadUrl(info)
local startupPath = AutoUpdate.getStartupPath()
State.updateInProgress = true
Utils.log("WARN", "Applying update " .. targetVersion .. " via " .. tostring(reason or "manual"))
if Audio and Audio.stop then
pcall(Audio.stop, "all")
end
if State.ws then
pcall(State.ws.close)
State.ws = nil
end
local body, err = AutoUpdate.fetch(downloadUrl)
if not body then
State.updateInProgress = false
return false, err
end
local ok, writeErr = AutoUpdate.writeFile(startupPath, body)
if not ok then
State.updateInProgress = false
return false, writeErr
end
Utils.log("SUCCESS", "Updated startup to " .. targetVersion)
Utils.log("WARN", "Rebooting to finish update")
os.sleep(1)
os.reboot()
return true
end
function AutoUpdate.checkOnBoot()
local info, err = AutoUpdate.fetchInfo()
if not info then
Utils.log("WARN", "Update check skipped: " .. tostring(err))
return false
end
if not AutoUpdate.needsUpdate(info) then
return false
end
Utils.log("INFO", "Found published update " .. AutoUpdate.getTargetVersion(info) .. " on boot")
local ok, applyErr = AutoUpdate.apply(info, "boot")
if not ok then
Utils.log("ERROR", "Boot update failed: " .. tostring(applyErr))
end
return ok
end
-- ============================================================================
-- 6. Network System
-- ============================================================================
@@ -1990,6 +2148,25 @@ function Network.messageLoop()
local flushBelow = data.flushBelow
Audio.stop(mode, flushBelow)
end
elseif data.type == "station_update" then
local targetVersion = AutoUpdate.getTargetVersion(data)
if targetVersion == "" then
Utils.log("WARN", "Ignored station update without version")
elseif targetVersion == APP_VERSION_TEXT then
Utils.log("INFO", "Received update notice for current version " .. targetVersion)
else
Utils.log("WARN", "Received published update " .. targetVersion)
local okUpdate, updateErr = AutoUpdate.apply({
targetVersion = targetVersion,
version = targetVersion,
releaseId = data.releaseId,
downloadPath = data.downloadPath,
downloadUrl = data.downloadUrl
}, "publish")
if not okUpdate then
Utils.log("ERROR", "Station update failed: " .. tostring(updateErr))
end
end
end
else
if State.audioRx then
@@ -2842,6 +3019,7 @@ end
-- ============================================================================
-- 9. Bootstrap
-- ============================================================================
AutoUpdate.checkOnBoot()
Peripherals.init()
Utils.ensureSlaveIdentityConfigured()
Audio.loadStationList()
+1 -1
View File
@@ -1,4 +1,4 @@
local STATION_BROADCAST_URL = "https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/branch/main/station_broadcast.lua"
local STATION_BROADCAST_URL = "http://stabadmin.fse-media.group/downloads/station_broadcast.lua"
local function fetch(url)
if not http then