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:
@@ -929,13 +929,16 @@
|
|||||||
|
|
||||||
<!-- Sidebar: Status -->
|
<!-- Sidebar: Status -->
|
||||||
<div class="sidebar" style="min-width: 200px; display:flex; flex-direction:column; height:100%;">
|
<div class="sidebar" style="min-width: 200px; display:flex; flex-direction:column; height:100%;">
|
||||||
<!-- Version Control (Hidden since we don't use it anymore) -->
|
<!-- Station Update -->
|
||||||
<div class="version-panel" style="display:none;">
|
<div class="version-panel">
|
||||||
<div class="version-panel-header">
|
<div class="version-panel-header">
|
||||||
<span>Audio Version</span>
|
<span>Station Update</span>
|
||||||
<button class="btn-icon" onclick="fetchVersion()" title="Refresh Version">
|
<div style="display:flex; gap:8px;">
|
||||||
<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 class="btn" id="publish-update-btn" onclick="publishStationUpdate()" title="Publish Station Update">发布更新</button>
|
||||||
</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>
|
||||||
<div class="version-display" id="current-version">Loading...</div>
|
<div class="version-display" id="current-version">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1072,6 +1075,7 @@
|
|||||||
let currentPath = 'root';
|
let currentPath = 'root';
|
||||||
let stationCodeMap = {};
|
let stationCodeMap = {};
|
||||||
let broadcastingStations = new Set();
|
let broadcastingStations = new Set();
|
||||||
|
let currentStationUpdate = null;
|
||||||
|
|
||||||
// --- WebSocket ---
|
// --- WebSocket ---
|
||||||
const connStatus = document.getElementById('conn-status');
|
const connStatus = document.getElementById('conn-status');
|
||||||
@@ -1118,6 +1122,11 @@
|
|||||||
|
|
||||||
// --- Renderers ---
|
// --- Renderers ---
|
||||||
function updateState(state) {
|
function updateState(state) {
|
||||||
|
if (state.stationUpdate) {
|
||||||
|
currentStationUpdate = state.stationUpdate;
|
||||||
|
renderStationUpdate(state.stationUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
if (state.stations) {
|
if (state.stations) {
|
||||||
const list = document.getElementById('station-list');
|
const list = document.getElementById('station-list');
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
@@ -1136,6 +1145,12 @@
|
|||||||
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
|
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
|
||||||
const stationTarget = st.target || st.name;
|
const stationTarget = st.target || st.name;
|
||||||
const memberCount = Number(st.memberCount || 1);
|
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');
|
const div = document.createElement('div');
|
||||||
div.className = 'status-item';
|
div.className = 'status-item';
|
||||||
div.id = `station-item-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
div.id = `station-item-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||||
@@ -1145,8 +1160,9 @@
|
|||||||
<div class="item-title">
|
<div class="item-title">
|
||||||
${st.name}
|
${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>' : ''}
|
${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>
|
||||||
<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>
|
||||||
<div style="display:flex;align-items:center;gap:10px">
|
<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>
|
<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
|
// Init
|
||||||
refreshFiles();
|
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 {
|
try {
|
||||||
const res = await fetch('/api/version');
|
const res = await fetch('/api/station-update');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.version) {
|
currentStationUpdate = data;
|
||||||
document.getElementById('current-version').textContent = data.version;
|
renderStationUpdate(data);
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('current-version').textContent = 'Error';
|
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 ---
|
// --- Resizers ---
|
||||||
const resizerV = document.getElementById('resizer-v');
|
const resizerV = document.getElementById('resizer-v');
|
||||||
const resizerH = document.getElementById('resizer-h');
|
const resizerH = document.getElementById('resizer-h');
|
||||||
|
|||||||
+138
-4
@@ -49,6 +49,8 @@ const audioGeneralLegacyDir = path.join(audioRootDir, 'general');
|
|||||||
const audioStationDir = path.join(audioRootDir, 'station');
|
const audioStationDir = path.join(audioRootDir, 'station');
|
||||||
const listFilePath = path.join(appRootDir, 'list.txt');
|
const listFilePath = path.join(appRootDir, 'list.txt');
|
||||||
const tempBroadcastDir = path.join(appRootDir, 'temp-broadcast');
|
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 STREAMABLE_AUDIO_EXTENSIONS = ['.wav', '.mp3', '.ogg', '.flac', '.m4a', '.aac', '.opus'];
|
||||||
const MUSIC_AUDIO_EXTENSIONS = [...STREAMABLE_AUDIO_EXTENSIONS, '.dfpwm'];
|
const MUSIC_AUDIO_EXTENSIONS = [...STREAMABLE_AUDIO_EXTENSIONS, '.dfpwm'];
|
||||||
|
|
||||||
@@ -190,6 +192,11 @@ let activeTrains = [];
|
|||||||
let stationLogs = [];
|
let stationLogs = [];
|
||||||
let audioVersion = String(Date.now());
|
let audioVersion = String(Date.now());
|
||||||
let versions = [];
|
let versions = [];
|
||||||
|
let stationUpdate = {
|
||||||
|
version: '',
|
||||||
|
releaseId: '',
|
||||||
|
publishedAt: 0
|
||||||
|
};
|
||||||
|
|
||||||
const DATA_FILE = path.join(__dirname, 'server-data.json');
|
const DATA_FILE = path.join(__dirname, 'server-data.json');
|
||||||
|
|
||||||
@@ -199,6 +206,94 @@ function delay(ms) {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, 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) {
|
function getInterruptModeForPriority(priority) {
|
||||||
if (priority >= 2) return 'all';
|
if (priority >= 2) return 'all';
|
||||||
if (priority >= 1) return 'ifLower';
|
if (priority >= 1) return 'ifLower';
|
||||||
@@ -281,6 +376,7 @@ function getStationGroups() {
|
|||||||
code,
|
code,
|
||||||
platform,
|
platform,
|
||||||
lastSeen: st.lastSeen || 0,
|
lastSeen: st.lastSeen || 0,
|
||||||
|
version: st.version || '',
|
||||||
source: 'cc',
|
source: 'cc',
|
||||||
tags: ['CC'],
|
tags: ['CC'],
|
||||||
memberNames: [st.name],
|
memberNames: [st.name],
|
||||||
@@ -295,6 +391,9 @@ function getStationGroups() {
|
|||||||
group.memberCount = group.memberNames.length;
|
group.memberCount = group.memberNames.length;
|
||||||
if ((st.lastSeen || 0) > (group.lastSeen || 0)) {
|
if ((st.lastSeen || 0) > (group.lastSeen || 0)) {
|
||||||
group.lastSeen = st.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.activeTrains) activeTrains = data.activeTrains;
|
||||||
if (data.audioVersion) audioVersion = String(data.audioVersion);
|
if (data.audioVersion) audioVersion = String(data.audioVersion);
|
||||||
if (Array.isArray(data.versions)) versions = data.versions;
|
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();
|
refreshStationIndex();
|
||||||
log('INFO', 'Data loaded from disk');
|
log('INFO', 'Data loaded from disk');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -846,7 +952,8 @@ function saveData() {
|
|||||||
stations: stationsToSave,
|
stations: stationsToSave,
|
||||||
activeTrains,
|
activeTrains,
|
||||||
audioVersion,
|
audioVersion,
|
||||||
versions
|
versions,
|
||||||
|
stationUpdate
|
||||||
};
|
};
|
||||||
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
|
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1672,9 +1779,10 @@ function handleMessage(ws, data) {
|
|||||||
// Send initial state
|
// Send initial state
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'init_state',
|
type: 'init_state',
|
||||||
stations,
|
stations: getMergedStationList(),
|
||||||
activeTrains,
|
activeTrains,
|
||||||
logs: stationLogs
|
logs: stationLogs,
|
||||||
|
stationUpdate: getStationUpdateSummary()
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1733,6 +1841,7 @@ function handleHeartbeat(ws, data) {
|
|||||||
};
|
};
|
||||||
normalizeStationRecord(stations[cleanName]);
|
normalizeStationRecord(stations[cleanName]);
|
||||||
log('INFO', `Station auto-registered via heartbeat: "${cleanName}"${data.stationVersion ? ` ${data.stationVersion}` : ''}`);
|
log('INFO', `Station auto-registered via heartbeat: "${cleanName}"${data.stationVersion ? ` ${data.stationVersion}` : ''}`);
|
||||||
|
maybeSendStationUpdate(ws, cleanName, data.stationVersion || stations[cleanName].version);
|
||||||
broadcastUpdate();
|
broadcastUpdate();
|
||||||
} else {
|
} else {
|
||||||
stations[cleanName].lastSeen = Date.now();
|
stations[cleanName].lastSeen = Date.now();
|
||||||
@@ -1745,6 +1854,7 @@ function handleHeartbeat(ws, data) {
|
|||||||
broadcastUpdate();
|
broadcastUpdate();
|
||||||
}
|
}
|
||||||
normalizeStationRecord(stations[cleanName]);
|
normalizeStationRecord(stations[cleanName]);
|
||||||
|
maybeSendStationUpdate(ws, cleanName, data.stationVersion || stations[cleanName].version);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1771,6 +1881,7 @@ function handleStationRegister(ws, data) {
|
|||||||
stationName: cleanName
|
stationName: cleanName
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
maybeSendStationUpdate(ws, cleanName, stationVersion);
|
||||||
saveData();
|
saveData();
|
||||||
broadcastUpdate();
|
broadcastUpdate();
|
||||||
}
|
}
|
||||||
@@ -2066,11 +2177,19 @@ function broadcastUpdate() {
|
|||||||
const state = {
|
const state = {
|
||||||
type: 'state_update',
|
type: 'state_update',
|
||||||
stations: list,
|
stations: list,
|
||||||
activeTrains
|
activeTrains,
|
||||||
|
stationUpdate: getStationUpdateSummary()
|
||||||
};
|
};
|
||||||
broadcastToConsole(state);
|
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) => {
|
app.get('/api/streams', (req, res) => {
|
||||||
const streams = [];
|
const streams = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -2193,6 +2312,21 @@ app.get('/api/version', (req, res) => {
|
|||||||
res.json({ version: audioVersion });
|
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) => {
|
app.post('/api/publish-version', (req, res) => {
|
||||||
const version = normalizeVersionString(req.body?.version) || String(Date.now());
|
const version = normalizeVersionString(req.body?.version) || String(Date.now());
|
||||||
const snap = snapshotFiles();
|
const snap = snapshotFiles();
|
||||||
|
|||||||
@@ -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)
|
local function fetch(url)
|
||||||
if not http then
|
if not http then
|
||||||
|
|||||||
+180
-2
@@ -2,11 +2,12 @@
|
|||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- 1. Configuration & Constants
|
-- 1. Configuration & Constants
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
local APP_VERSION_TEXT = "v1.0.44"
|
local APP_VERSION_TEXT = "v1.0.45"
|
||||||
|
|
||||||
local CONFIG = {
|
local CONFIG = {
|
||||||
CLOUD_URL = "ws://stabadmin.fse-media.group",
|
CLOUD_URL = "ws://stabadmin.fse-media.group",
|
||||||
API_URL = "http://stabadmin.fse-media.group",
|
API_URL = "http://stabadmin.fse-media.group",
|
||||||
|
STARTUP_PATH = "/startup",
|
||||||
SYNC_INTERVAL = 30,
|
SYNC_INTERVAL = 30,
|
||||||
AUDIO_BASE = "Audio/",
|
AUDIO_BASE = "Audio/",
|
||||||
AUDIO_STATION = "Audio/Station/",
|
AUDIO_STATION = "Audio/Station/",
|
||||||
@@ -55,7 +56,8 @@ local State = {
|
|||||||
currentPlaying = nil,
|
currentPlaying = nil,
|
||||||
currentPriority = -1,
|
currentPriority = -1,
|
||||||
interruptSeq = 0,
|
interruptSeq = 0,
|
||||||
tickRate = 20
|
tickRate = 20,
|
||||||
|
updateInProgress = false
|
||||||
}
|
}
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -1813,6 +1815,162 @@ function Utils.getStationAudio(code, langPrefix)
|
|||||||
return langPrefix .. code
|
return langPrefix .. code
|
||||||
end
|
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
|
-- 6. Network System
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -1990,6 +2148,25 @@ function Network.messageLoop()
|
|||||||
local flushBelow = data.flushBelow
|
local flushBelow = data.flushBelow
|
||||||
Audio.stop(mode, flushBelow)
|
Audio.stop(mode, flushBelow)
|
||||||
end
|
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
|
end
|
||||||
else
|
else
|
||||||
if State.audioRx then
|
if State.audioRx then
|
||||||
@@ -2842,6 +3019,7 @@ end
|
|||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- 9. Bootstrap
|
-- 9. Bootstrap
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
AutoUpdate.checkOnBoot()
|
||||||
Peripherals.init()
|
Peripherals.init()
|
||||||
Utils.ensureSlaveIdentityConfigured()
|
Utils.ensureSlaveIdentityConfigured()
|
||||||
Audio.loadStationList()
|
Audio.loadStationList()
|
||||||
|
|||||||
@@ -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)
|
local function fetch(url)
|
||||||
if not http then
|
if not http then
|
||||||
|
|||||||
Reference in New Issue
Block a user