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 });