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.
This commit is contained in:
2026-08-01 07:44:38 +08:00
parent fd53eaef97
commit 304d413482
4 changed files with 198 additions and 95 deletions
+4 -2
View File
@@ -925,6 +925,8 @@
const isBroadcasting = broadcastingStations.has(st.name); const isBroadcasting = broadcastingStations.has(st.name);
const dotClass = isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline'); const dotClass = isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline');
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 memberCount = Number(st.memberCount || 1);
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, '_')}`;
@@ -935,11 +937,11 @@
${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>' : ''}
</div> </div>
<div class="item-sub">${st.code}${st.platform ? ` - Platform ${st.platform}` : ''}</div> <div class="item-sub">${st.code}${st.platform ? ` - Platform ${st.platform}` : ''}${memberCount > 1 ? ` · Nodes ${memberCount}` : ''}</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>
<button class="btn-icon" onclick="deleteStation('${st.name}')">${ICONS.trash}</button> <button class="btn-icon" onclick="deleteStation('${stationTarget}')">${ICONS.trash}</button>
</div> </div>
</div> </div>
`; `;
+4 -2
View File
@@ -279,14 +279,16 @@
} }
for (const st of stations) { for (const st of stations) {
const name = st.name || ""; const name = st.name || "";
const target = st.target || name;
const code = st.code || ""; const code = st.code || "";
const memberCount = Number(st.memberCount || 1);
const lastSeen = st.lastSeen ? new Date(st.lastSeen).toLocaleString() : ""; const lastSeen = st.lastSeen ? new Date(st.lastSeen).toLocaleString() : "";
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "station-item"; row.className = "station-item";
row.innerHTML = ` row.innerHTML = `
<input type="checkbox" data-station-name="1" data-name="${escapeHtml(name)}" onchange="updateSelectedBadge()" /> <input type="checkbox" data-station-name="1" data-name="${escapeHtml(target)}" onchange="updateSelectedBadge()" />
<div class="station-name">${escapeHtml(name)}</div> <div class="station-name">${escapeHtml(name)}</div>
<div class="station-meta">${escapeHtml(code)}${lastSeen ? " · " + escapeHtml(lastSeen) : ""}</div> <div class="station-meta">${escapeHtml(code)}${memberCount > 1 ? " · 从机 " + memberCount : ""}${lastSeen ? " · " + escapeHtml(lastSeen) : ""}</div>
`; `;
box.appendChild(row); box.appendChild(row);
} }
+4 -2
View File
@@ -274,14 +274,16 @@
} }
for (const st of stations) { for (const st of stations) {
const name = st.name || ""; const name = st.name || "";
const target = st.target || name;
const code = st.code || ""; const code = st.code || "";
const memberCount = Number(st.memberCount || 1);
const lastSeen = st.lastSeen ? new Date(st.lastSeen).toLocaleString() : ""; const lastSeen = st.lastSeen ? new Date(st.lastSeen).toLocaleString() : "";
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "station-item"; row.className = "station-item";
row.innerHTML = ` row.innerHTML = `
<input type="checkbox" data-station-name="1" data-name="${escapeHtml(name)}" onchange="updateSelectedBadge()" /> <input type="checkbox" data-station-name="1" data-name="${escapeHtml(target)}" onchange="updateSelectedBadge()" />
<div class="station-name">${escapeHtml(name)}</div> <div class="station-name">${escapeHtml(name)}</div>
<div class="station-meta">${escapeHtml(code)}${lastSeen ? " · " + escapeHtml(lastSeen) : ""}</div> <div class="station-meta">${escapeHtml(code)}${memberCount > 1 ? " · 从机 " + memberCount : ""}${lastSeen ? " · " + escapeHtml(lastSeen) : ""}</div>
`; `;
box.appendChild(row); box.appendChild(row);
} }
+186 -89
View File
@@ -215,23 +215,105 @@ function getStationWs(stationName, fallbackWs) {
return null; return null;
} }
function getMergedStationList() { function getStationGroupKey(station) {
const list = []; 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) { for (const k in stations) {
if (!Object.prototype.hasOwnProperty.call(stations, k)) continue; if (!Object.prototype.hasOwnProperty.call(stations, k)) continue;
const st = stations[k]; const st = stations[k];
const stCodeRaw = st.code || getStationCodeFromName(st.name) || ''; const code = normalizeStationCode(st.code || getStationCodeFromName(st.name)) || '';
const code = stCodeRaw.replace(/_/g, '-').toLowerCase(); const platform = normalizeStationPlatform(st.platform || getPlatformNumberFromName(st.name));
list.push({ const key = getStationGroupKey(st);
name: st.name, if (!key) continue;
code: code || '',
platform: st.platform || getPlatformNumberFromName(st.name), if (!groups.has(key)) {
lastSeen: st.lastSeen, groups.set(key, {
source: 'cc', key,
tags: ['CC'] 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) { 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) => { app.delete('/api/stations/:name', (req, res) => {
const name = req.params.name; const selector = String(req.params.name || '').trim();
if (stations[name]) { const group = resolveStationGroup(selector);
delete stations[name]; if (group && Array.isArray(group.memberNames) && group.memberNames.length > 0) {
log('INFO', `Deleted station: ${name}`); for (const memberName of group.memberNames) {
delete stations[memberName];
}
log('INFO', `Deleted station target: ${selector} (${group.memberNames.length} node(s))`);
saveData(); saveData();
broadcastUpdate(); broadcastUpdate();
res.json({ success: true }); res.json({ success: true });
@@ -2451,29 +2536,36 @@ app.post('/api/temp-broadcast/publish', (req, res) => {
let failed = 0; let failed = 0;
const results = []; const results = [];
for (const rawName of stationsReq) { for (const group of expandStationTargets(stationsReq)) {
const name = String(rawName || '').trim(); const onlineMembers = group.memberNames.filter(memberName => {
if (!name) continue; const ws = getStationWs(memberName);
const ws = getStationWs(name); return !!ws && ws.readyState === WebSocket.OPEN;
if (!ws || ws.readyState !== WebSocket.OPEN) { });
if (onlineMembers.length === 0) {
failed++; failed++;
results.push({ stationName: name, ok: false, reason: 'offline' }); results.push({ stationName: group.name, ok: false, reason: 'offline', memberCount: group.memberCount });
continue; continue;
} }
enqueueStationAudioTask(name, ws, {
audioFormat: 'stream', for (let idx = 0; idx < onlineMembers.length; idx++) {
streamUrl, const memberName = onlineMembers[idx];
durationSec, const ws = getStationWs(memberName);
priority, enqueueStationAudioTask(memberName, ws, {
interruptMode, audioFormat: 'stream',
source, streamUrl,
audioId: `temp-${broadcastId}-${name}-${Date.now()}`, durationSec,
requeueOnPreempt, priority,
consoleStreamStart: { type: 'stream_start', station: name, seq: [`TEMP:${broadcastId}`], streamName: getStationStreamName(name), hlsUrl: getStationHlsUrl(getStationStreamName(name)) }, interruptMode,
consoleStreamEnd: { type: 'stream_end', station: name } 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++; 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 }); res.json({ success: true, sent, failed, results });
@@ -2502,68 +2594,73 @@ app.post('/api/music/start', express.json(), (req, res) => {
let failed = 0; let failed = 0;
const results = []; const results = [];
for (const rawName of stationsReq) { for (const group of expandStationTargets(stationsReq)) {
const name = String(rawName || '').trim(); const onlineMembers = group.memberNames.filter(memberName => {
if (!name) continue; const ws = getStationWs(memberName);
const ws = getStationWs(name); return !!ws && ws.readyState === WebSocket.OPEN;
if (!ws || ws.readyState !== WebSocket.OPEN) { });
if (onlineMembers.length === 0) {
failed++; failed++;
results.push({ stationName: name, ok: false, reason: 'offline' }); results.push({ stationName: group.name, ok: false, reason: 'offline', memberCount: group.memberCount });
continue; continue;
} }
stopStationMusic(name, ws); for (let idx = 0; idx < onlineMembers.length; idx++) {
stationMusicStates.set(name, { enabled: true, track, loop }); const memberName = onlineMembers[idx];
const ws = getStationWs(memberName);
stopStationMusic(memberName, ws);
stationMusicStates.set(memberName, { enabled: true, track, loop });
const streamName = getStationStreamName(name); const streamName = getStationStreamName(memberName);
const hlsUrl = HLS_OUTPUT_MODE === 'local' const hlsUrl = HLS_OUTPUT_MODE === 'local'
? getLocalHlsUrlForRequest(streamName, req) ? getLocalHlsUrlForRequest(streamName, req)
: getStationHlsUrlForRequest(streamName, req); : getStationHlsUrlForRequest(streamName, req);
const enqueueOnce = () => { const enqueueOnce = () => {
const currentWs = getStationWs(name); const currentWs = getStationWs(memberName);
if (!currentWs || currentWs.readyState !== WebSocket.OPEN) { if (!currentWs || currentWs.readyState !== WebSocket.OPEN) {
// If offline, retry later instead of giving up setTimeout(enqueueOnce, 5000);
setTimeout(enqueueOnce, 5000); return;
return; }
}
if (playbackAudio.sourcePath) { if (playbackAudio.sourcePath) {
enqueueStationAudioTask(name, currentWs, { enqueueStationAudioTask(memberName, currentWs, {
audioFormat: 'wav-stream-live', audioFormat: 'wav-stream-live',
sourcePath: playbackAudio.sourcePath, 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, durationSec: playbackAudio.durationSec,
priority: -1, priority: -1,
interruptMode: 'none', interruptMode: 'none',
source: 'music', source: 'music',
loop, loop,
audioId: `music-${track}-${name}-${Date.now()}`, audioId: `music-${track}-${memberName}-${Date.now()}`,
requeueOnPreempt: true, requeueOnPreempt: true,
consoleStreamStart: { type: 'stream_start', station: name, seq: [`MUSIC:${track}`], streamName, hlsUrl }, consoleStreamStart: idx === 0 ? { type: 'stream_start', station: group.name, seq: [`MUSIC:${track}`], streamName, hlsUrl } : null,
consoleStreamEnd: { type: 'stream_end', station: name } consoleStreamEnd: idx === 0 ? { type: 'stream_end', station: group.name } : null
}); });
return; };
}
enqueueStationAudioTask(name, currentWs, { enqueueOnce();
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();
sent++; 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 }); res.json({ success: true, sent, failed, results });
@@ -2576,14 +2673,14 @@ app.post('/api/music/stop', express.json(), (req, res) => {
let stopped = 0; let stopped = 0;
const results = []; const results = [];
for (const rawName of stationsReq) { for (const group of expandStationTargets(stationsReq)) {
const name = String(rawName || '').trim(); for (const memberName of group.memberNames) {
if (!name) continue; const ws = getStationWs(memberName);
const ws = getStationWs(name); stationMusicStates.set(memberName, { enabled: false, track: null, loop: false });
stationMusicStates.set(name, { enabled: false, track: null, loop: false }); stopStationMusic(memberName, ws);
stopStationMusic(name, ws); }
stopped++; stopped++;
results.push({ stationName: name, ok: true }); results.push({ stationName: group.name, ok: true, memberCount: group.memberCount });
} }
res.json({ success: true, stopped, results }); res.json({ success: true, stopped, results });