From 304d4134827311a265959e93cd0c464ec369459d Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 1 Aug 2026 07:44:38 +0800
Subject: [PATCH] feat(station): group slave nodes by station target
Merge stations with the same station code and platform into one logical target, and expand music and temp broadcast playback to all online slave nodes while the console and picker UIs show grouped station entries with node counts.
---
CloudServer/public/index.html | 6 +-
CloudServer/public/music.html | 6 +-
CloudServer/public/tempbroadcast.html | 6 +-
CloudServer/server.js | 275 +++++++++++++++++---------
4 files changed, 198 insertions(+), 95 deletions(-)
diff --git a/CloudServer/public/index.html b/CloudServer/public/index.html
index 38d1875..ef4914b 100644
--- a/CloudServer/public/index.html
+++ b/CloudServer/public/index.html
@@ -925,6 +925,8 @@
const isBroadcasting = broadcastingStations.has(st.name);
const dotClass = isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline');
const hasCC = Array.isArray(st.tags) && st.tags.includes('CC');
+ const stationTarget = st.target || st.name;
+ const memberCount = Number(st.memberCount || 1);
const div = document.createElement('div');
div.className = 'status-item';
div.id = `station-item-${st.name.replace(/[^a-zA-Z0-9]/g, '_')}`;
@@ -935,11 +937,11 @@
${st.name}
${hasCC ? 'CC' : ''}
-
${st.code}${st.platform ? ` - Platform ${st.platform}` : ''}
+ ${st.code}${st.platform ? ` - Platform ${st.platform}` : ''}${memberCount > 1 ? ` · Nodes ${memberCount}` : ''}
-
+
`;
diff --git a/CloudServer/public/music.html b/CloudServer/public/music.html
index f7d4e34..7479390 100644
--- a/CloudServer/public/music.html
+++ b/CloudServer/public/music.html
@@ -279,14 +279,16 @@
}
for (const st of stations) {
const name = st.name || "";
+ const target = st.target || name;
const code = st.code || "";
+ const memberCount = Number(st.memberCount || 1);
const lastSeen = st.lastSeen ? new Date(st.lastSeen).toLocaleString() : "";
const row = document.createElement("div");
row.className = "station-item";
row.innerHTML = `
-
+
${escapeHtml(name)}
- ${escapeHtml(code)}${lastSeen ? " · " + escapeHtml(lastSeen) : ""}
+ ${escapeHtml(code)}${memberCount > 1 ? " · 从机 " + memberCount : ""}${lastSeen ? " · " + escapeHtml(lastSeen) : ""}
`;
box.appendChild(row);
}
diff --git a/CloudServer/public/tempbroadcast.html b/CloudServer/public/tempbroadcast.html
index 976e079..8f553d1 100644
--- a/CloudServer/public/tempbroadcast.html
+++ b/CloudServer/public/tempbroadcast.html
@@ -274,14 +274,16 @@
}
for (const st of stations) {
const name = st.name || "";
+ const target = st.target || name;
const code = st.code || "";
+ const memberCount = Number(st.memberCount || 1);
const lastSeen = st.lastSeen ? new Date(st.lastSeen).toLocaleString() : "";
const row = document.createElement("div");
row.className = "station-item";
row.innerHTML = `
-
+
${escapeHtml(name)}
- ${escapeHtml(code)}${lastSeen ? " · " + escapeHtml(lastSeen) : ""}
+ ${escapeHtml(code)}${memberCount > 1 ? " · 从机 " + memberCount : ""}${lastSeen ? " · " + escapeHtml(lastSeen) : ""}
`;
box.appendChild(row);
}
diff --git a/CloudServer/server.js b/CloudServer/server.js
index 3044a3c..b40c2bc 100644
--- a/CloudServer/server.js
+++ b/CloudServer/server.js
@@ -215,23 +215,105 @@ function getStationWs(stationName, fallbackWs) {
return null;
}
-function getMergedStationList() {
- const list = [];
+function getStationGroupKey(station) {
+ if (!station) return null;
+ const code = normalizeStationCode(station.code || getStationCodeFromName(station.name));
+ const platform = normalizeStationPlatform(station.platform || getPlatformNumberFromName(station.name));
+ if (code) return `${code}@@${platform}`;
+ return `name@@${String(station.name || '').trim()}`;
+}
+
+function getStationGroupTarget(station) {
+ const groupKey = getStationGroupKey(station);
+ return groupKey ? `group:${groupKey}` : null;
+}
+
+function getStationGroups() {
+ const groups = new Map();
+
for (const k in stations) {
if (!Object.prototype.hasOwnProperty.call(stations, k)) continue;
const st = stations[k];
- const stCodeRaw = st.code || getStationCodeFromName(st.name) || '';
- const code = stCodeRaw.replace(/_/g, '-').toLowerCase();
- list.push({
- name: st.name,
- code: code || '',
- platform: st.platform || getPlatformNumberFromName(st.name),
- lastSeen: st.lastSeen,
- source: 'cc',
- tags: ['CC']
- });
+ const code = normalizeStationCode(st.code || getStationCodeFromName(st.name)) || '';
+ const platform = normalizeStationPlatform(st.platform || getPlatformNumberFromName(st.name));
+ const key = getStationGroupKey(st);
+ if (!key) continue;
+
+ if (!groups.has(key)) {
+ groups.set(key, {
+ key,
+ target: getStationGroupTarget(st),
+ name: st.name,
+ code,
+ platform,
+ lastSeen: st.lastSeen || 0,
+ source: 'cc',
+ tags: ['CC'],
+ memberNames: [st.name],
+ memberCount: 1,
+ primaryName: st.name
+ });
+ continue;
+ }
+
+ const group = groups.get(key);
+ group.memberNames.push(st.name);
+ group.memberCount = group.memberNames.length;
+ if ((st.lastSeen || 0) > (group.lastSeen || 0)) {
+ group.lastSeen = st.lastSeen || 0;
+ }
}
- return list;
+
+ return Array.from(groups.values()).map(group => ({
+ ...group,
+ tags: group.memberCount > 1 ? ['CC', `GROUP:${group.memberCount}`] : ['CC']
+ }));
+}
+
+function resolveStationGroup(selector) {
+ const clean = String(selector || '').trim();
+ if (!clean) return null;
+
+ if (Object.prototype.hasOwnProperty.call(stations, clean)) {
+ const st = stations[clean];
+ return {
+ target: getStationGroupTarget(st) || clean,
+ name: st.name,
+ code: normalizeStationCode(st.code || getStationCodeFromName(st.name)) || '',
+ platform: normalizeStationPlatform(st.platform || getPlatformNumberFromName(st.name)),
+ memberNames: [st.name],
+ memberCount: 1,
+ primaryName: st.name
+ };
+ }
+
+ for (const group of getStationGroups()) {
+ if (group.target === clean || group.name === clean) {
+ return group;
+ }
+ }
+
+ return null;
+}
+
+function expandStationTargets(selectors) {
+ const groups = [];
+ const seenTargets = new Set();
+
+ for (const raw of Array.isArray(selectors) ? selectors : []) {
+ const group = resolveStationGroup(raw);
+ if (!group) continue;
+ const target = String(group.target || group.name || '').trim();
+ if (!target || seenTargets.has(target)) continue;
+ seenTargets.add(target);
+ groups.push(group);
+ }
+
+ return groups;
+}
+
+function getMergedStationList() {
+ return getStationGroups();
}
function sendAudioControl(stationName, msg, fallbackWs) {
@@ -2025,10 +2107,13 @@ app.put('/api/stations/:name', express.json(), (req, res) => {
});
app.delete('/api/stations/:name', (req, res) => {
- const name = req.params.name;
- if (stations[name]) {
- delete stations[name];
- log('INFO', `Deleted station: ${name}`);
+ const selector = String(req.params.name || '').trim();
+ const group = resolveStationGroup(selector);
+ if (group && Array.isArray(group.memberNames) && group.memberNames.length > 0) {
+ for (const memberName of group.memberNames) {
+ delete stations[memberName];
+ }
+ log('INFO', `Deleted station target: ${selector} (${group.memberNames.length} node(s))`);
saveData();
broadcastUpdate();
res.json({ success: true });
@@ -2451,29 +2536,36 @@ app.post('/api/temp-broadcast/publish', (req, res) => {
let failed = 0;
const results = [];
- for (const rawName of stationsReq) {
- const name = String(rawName || '').trim();
- if (!name) continue;
- const ws = getStationWs(name);
- if (!ws || ws.readyState !== WebSocket.OPEN) {
+ for (const group of expandStationTargets(stationsReq)) {
+ const onlineMembers = group.memberNames.filter(memberName => {
+ const ws = getStationWs(memberName);
+ return !!ws && ws.readyState === WebSocket.OPEN;
+ });
+
+ if (onlineMembers.length === 0) {
failed++;
- results.push({ stationName: name, ok: false, reason: 'offline' });
+ results.push({ stationName: group.name, ok: false, reason: 'offline', memberCount: group.memberCount });
continue;
}
- enqueueStationAudioTask(name, ws, {
- audioFormat: 'stream',
- streamUrl,
- durationSec,
- priority,
- interruptMode,
- source,
- audioId: `temp-${broadcastId}-${name}-${Date.now()}`,
- requeueOnPreempt,
- consoleStreamStart: { type: 'stream_start', station: name, seq: [`TEMP:${broadcastId}`], streamName: getStationStreamName(name), hlsUrl: getStationHlsUrl(getStationStreamName(name)) },
- consoleStreamEnd: { type: 'stream_end', station: name }
- });
+
+ for (let idx = 0; idx < onlineMembers.length; idx++) {
+ const memberName = onlineMembers[idx];
+ const ws = getStationWs(memberName);
+ enqueueStationAudioTask(memberName, ws, {
+ audioFormat: 'stream',
+ streamUrl,
+ durationSec,
+ priority,
+ interruptMode,
+ source,
+ audioId: `temp-${broadcastId}-${memberName}-${Date.now()}`,
+ requeueOnPreempt,
+ consoleStreamStart: idx === 0 ? { type: 'stream_start', station: group.name, seq: [`TEMP:${broadcastId}`], streamName: getStationStreamName(memberName), hlsUrl: getStationHlsUrl(getStationStreamName(memberName)) } : null,
+ consoleStreamEnd: idx === 0 ? { type: 'stream_end', station: group.name } : null
+ });
+ }
sent++;
- results.push({ stationName: name, ok: true });
+ results.push({ stationName: group.name, ok: true, memberCount: onlineMembers.length, totalMembers: group.memberCount });
}
res.json({ success: true, sent, failed, results });
@@ -2502,68 +2594,73 @@ app.post('/api/music/start', express.json(), (req, res) => {
let failed = 0;
const results = [];
- for (const rawName of stationsReq) {
- const name = String(rawName || '').trim();
- if (!name) continue;
- const ws = getStationWs(name);
- if (!ws || ws.readyState !== WebSocket.OPEN) {
+ for (const group of expandStationTargets(stationsReq)) {
+ const onlineMembers = group.memberNames.filter(memberName => {
+ const ws = getStationWs(memberName);
+ return !!ws && ws.readyState === WebSocket.OPEN;
+ });
+
+ if (onlineMembers.length === 0) {
failed++;
- results.push({ stationName: name, ok: false, reason: 'offline' });
+ results.push({ stationName: group.name, ok: false, reason: 'offline', memberCount: group.memberCount });
continue;
}
- stopStationMusic(name, ws);
- stationMusicStates.set(name, { enabled: true, track, loop });
+ for (let idx = 0; idx < onlineMembers.length; idx++) {
+ const memberName = onlineMembers[idx];
+ const ws = getStationWs(memberName);
+ stopStationMusic(memberName, ws);
+ stationMusicStates.set(memberName, { enabled: true, track, loop });
- const streamName = getStationStreamName(name);
- const hlsUrl = HLS_OUTPUT_MODE === 'local'
- ? getLocalHlsUrlForRequest(streamName, req)
- : getStationHlsUrlForRequest(streamName, req);
+ const streamName = getStationStreamName(memberName);
+ const hlsUrl = HLS_OUTPUT_MODE === 'local'
+ ? getLocalHlsUrlForRequest(streamName, req)
+ : getStationHlsUrlForRequest(streamName, req);
- const enqueueOnce = () => {
- const currentWs = getStationWs(name);
- if (!currentWs || currentWs.readyState !== WebSocket.OPEN) {
- // If offline, retry later instead of giving up
- setTimeout(enqueueOnce, 5000);
- return;
- }
+ const enqueueOnce = () => {
+ const currentWs = getStationWs(memberName);
+ if (!currentWs || currentWs.readyState !== WebSocket.OPEN) {
+ setTimeout(enqueueOnce, 5000);
+ return;
+ }
- if (playbackAudio.sourcePath) {
- enqueueStationAudioTask(name, currentWs, {
- audioFormat: 'wav-stream-live',
- sourcePath: playbackAudio.sourcePath,
+ if (playbackAudio.sourcePath) {
+ enqueueStationAudioTask(memberName, currentWs, {
+ audioFormat: 'wav-stream-live',
+ sourcePath: playbackAudio.sourcePath,
+ durationSec: playbackAudio.durationSec,
+ priority: -1,
+ interruptMode: 'none',
+ source: 'music',
+ loop,
+ audioId: `music-${track}-${memberName}-${Date.now()}`,
+ requeueOnPreempt: true,
+ consoleStreamStart: idx === 0 ? { type: 'stream_start', station: group.name, seq: [`MUSIC:${track}`], streamName, hlsUrl } : null,
+ consoleStreamEnd: idx === 0 ? { type: 'stream_end', station: group.name } : null
+ });
+ return;
+ }
+
+ enqueueStationAudioTask(memberName, currentWs, {
+ audioFormat: playbackAudio.audioFormat,
+ buffer: playbackAudio.buffer,
+ streamUrl: playbackAudio.streamUrl,
durationSec: playbackAudio.durationSec,
priority: -1,
interruptMode: 'none',
source: 'music',
loop,
- audioId: `music-${track}-${name}-${Date.now()}`,
+ audioId: `music-${track}-${memberName}-${Date.now()}`,
requeueOnPreempt: true,
- consoleStreamStart: { type: 'stream_start', station: name, seq: [`MUSIC:${track}`], streamName, hlsUrl },
- consoleStreamEnd: { type: 'stream_end', station: name }
+ consoleStreamStart: idx === 0 ? { type: 'stream_start', station: group.name, seq: [`MUSIC:${track}`], streamName, hlsUrl } : null,
+ consoleStreamEnd: idx === 0 ? { type: 'stream_end', station: group.name } : null
});
- return;
- }
+ };
- enqueueStationAudioTask(name, currentWs, {
- audioFormat: playbackAudio.audioFormat,
- buffer: playbackAudio.buffer,
- streamUrl: playbackAudio.streamUrl,
- durationSec: playbackAudio.durationSec,
- priority: -1,
- interruptMode: 'none',
- source: 'music',
- loop,
- audioId: `music-${track}-${name}-${Date.now()}`,
- requeueOnPreempt: true,
- consoleStreamStart: { type: 'stream_start', station: name, seq: [`MUSIC:${track}`], streamName, hlsUrl },
- consoleStreamEnd: { type: 'stream_end', station: name }
- });
- };
-
- enqueueOnce();
+ enqueueOnce();
+ }
sent++;
- results.push({ stationName: name, ok: true });
+ results.push({ stationName: group.name, ok: true, memberCount: onlineMembers.length, totalMembers: group.memberCount });
}
res.json({ success: true, sent, failed, results });
@@ -2576,14 +2673,14 @@ app.post('/api/music/stop', express.json(), (req, res) => {
let stopped = 0;
const results = [];
- for (const rawName of stationsReq) {
- const name = String(rawName || '').trim();
- if (!name) continue;
- const ws = getStationWs(name);
- stationMusicStates.set(name, { enabled: false, track: null, loop: false });
- stopStationMusic(name, ws);
+ for (const group of expandStationTargets(stationsReq)) {
+ for (const memberName of group.memberNames) {
+ const ws = getStationWs(memberName);
+ stationMusicStates.set(memberName, { enabled: false, track: null, loop: false });
+ stopStationMusic(memberName, ws);
+ }
stopped++;
- results.push({ stationName: name, ok: true });
+ results.push({ stationName: group.name, ok: true, memberCount: group.memberCount });
}
res.json({ success: true, stopped, results });