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