Remove CTM and inbound prediction

This commit is contained in:
2026-07-31 18:23:24 +08:00
commit e4b4fa5cdb
91 changed files with 15128 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.trae/
音频制作/
list.txt
CloudServer/node_modules/
CloudServer/tmp/
CloudServer/server-data.json
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
/*
* @Author: HenryDu8133 813367384@qq.com
* @Date: 2026-02-24 14:42:13
* @LastEditors: HenryDu8133 813367384@qq.com
* @LastEditTime: 2026-06-07 02:05:41
* @FilePath: \StationBroadcastDisplayCN\CloudServer\ecosystem.config.js
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
*/
module.exports = {
apps : [{
name : "station-cloud-server",
script : "./server.js",
env: {
NODE_ENV: "production"
}
}]
}
+1350
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "cloudserver",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"body-parser": "^2.2.2",
"dfpwm": "^1.2.2",
"ejs": "^4.0.1",
"express": "^5.2.1",
"https-proxy-agent": "^5.0.1",
"multer": "^2.0.2",
"node-fetch": "^3.3.2",
"socks-proxy-agent": "^9.0.0",
"undici": "^7.24.6",
"wavefile": "^11.0.0",
"ws": "^8.19.0"
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+598
View File
@@ -0,0 +1,598 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>车站广播系统 - 实时信息板</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet">
<style>
:root {
--bg-body: #09090b;
--bg-panel: #18181b;
--bg-hover: #27272a;
--bg-border: #27272a;
--text-primary: #e4e4e7;
--text-muted: #71717a;
--accent: #fafafa;
--danger: #ef4444;
--success: #10b981;
--warning: #f59e0b;
--radius: 8px;
--font-ui: 'Inter', -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
* {
box-sizing: border-box;
}
body {
font-family: var(--font-ui);
background-color: var(--bg-body);
color: var(--text-primary);
margin: 0;
padding: 24px;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--bg-border);
}
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.025em;
}
.header-meta {
display: flex;
gap: 20px;
align-items: center;
font-size: 0.85rem;
color: var(--text-muted);
}
.status-badge {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 24px;
flex: 1;
overflow: hidden;
}
.card {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
overflow: hidden;
}
.card-header {
padding: 16px 20px;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.card-content {
flex: 1;
overflow-y: auto;
padding: 0;
}
/* Status Styles */
.status-list {
list-style: none;
margin: 0;
padding: 0;
}
.status-item {
padding: 12px 20px;
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
}
.status-item:last-child {
border-bottom: none;
}
.item-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.item-title {
font-weight: 500;
font-size: 0.95rem;
}
.item-sub {
font-size: 0.75rem;
color: var(--text-muted);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--bg-border);
}
.dot-online {
background: var(--success);
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
}
.dot-offline {
background: var(--danger);
}
.dot-broadcasting {
background: #3b82f6;
box-shadow: 0 0 8px rgba(59, 130, 246, 0.6);
animation: pulse 1s infinite;
}
@keyframes pulse {
0% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.5); }
100% { opacity: 1; transform: scale(1); }
}
/* Train Specifics */
.train-item {
cursor: pointer;
transition: background 0.1s;
}
.train-item:hover {
background: var(--bg-hover);
}
.train-details {
display: none;
background: rgba(0, 0, 0, 0.2);
padding: 12px 20px;
font-size: 0.8rem;
color: var(--text-muted);
border-top: 1px solid var(--bg-border);
}
.train-details.open {
display: block;
}
.trip-log {
display: grid;
grid-template-columns: 80px 1fr auto;
gap: 12px;
padding: 4px 0;
border-bottom: 1px dashed var(--bg-border);
}
.trip-log:last-child {
border-bottom: none;
}
/* Statistics & Messages */
.log-list {
padding: 8px;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.log-item {
padding: 12px 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--bg-border);
border-radius: 6px;
font-size: 0.85rem;
line-height: 1.5;
display: flex;
align-items: flex-start;
gap: 12px;
animation: slideInRight 0.3s cubic-bezier(0.16, 1, 0.3, 1);
transition: all 0.2s;
}
.log-item:hover {
background: rgba(255, 255, 255, 0.05);
border-color: #3f3f46;
}
@keyframes slideInRight {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
.log-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
flex-shrink: 0;
margin-top: 2px;
}
.log-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.log-time {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-muted);
font-weight: 500;
}
.log-content {
color: var(--text-primary);
word-break: break-all;
}
/* Message Types */
.log-info .log-icon { background: rgba(59, 130, 246, 0.15); color: #60a5fa; }
.log-arrival .log-icon { background: rgba(16, 185, 129, 0.15); color: #34d399; }
.log-departure .log-icon { background: rgba(245, 158, 11, 0.15); color: #fbbf24; }
.log-broadcast .log-icon { background: rgba(139, 92, 246, 0.15); color: #a78bfa; }
.log-important { border-left: 3px solid #3b82f6; }
.log-broadcast {
background: rgba(139, 92, 246, 0.05);
border-color: rgba(139, 92, 246, 0.2);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--bg-hover);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #52525b;
}
@media (max-width: 1024px) {
.main-grid {
grid-template-columns: 1fr;
overflow-y: auto;
}
body {
overflow-y: auto;
height: auto;
}
}
</style>
</head>
<body>
<header>
<h1>实时信息板 - 全局概览</h1>
<div class="header-meta">
<div id="clock" style="font-family: var(--font-mono); font-weight: 500;">00:00:00</div>
<div class="status-badge">
<div id="conn-dot" class="status-dot dot-offline"></div>
<span id="conn-status" style="color: var(--danger)">未连接</span>
</div>
</div>
</header>
<div class="main-grid">
<!-- 车站状态 -->
<div class="card">
<div class="card-header">
<div class="card-title">活动车站</div>
<div id="station-count" style="font-size: 0.75rem; color: var(--text-muted);">0</div>
</div>
<div class="card-content">
<div class="status-list" id="station-list">
<div style="padding:40px; text-align:center; color:var(--text-muted); font-style:italic;">正在加载车站信息...</div>
</div>
</div>
</div>
<!-- 列车状态 -->
<div class="card">
<div class="card-header">
<div class="card-title">活跃列车</div>
<div id="train-count" style="font-size: 0.75rem; color: var(--text-muted);">0</div>
</div>
<div class="card-content">
<div class="status-list" id="train-list">
<div style="padding:40px; text-align:center; color:var(--text-muted); font-style:italic;">正在加载列车信息...</div>
</div>
</div>
</div>
<!-- 最新消息 -->
<div class="card">
<div class="card-header">
<div class="card-title">最新消息</div>
</div>
<div class="card-content">
<div class="log-list" id="message-list">
<div style="padding:40px; text-align:center; color:var(--text-muted); font-style:italic;">暂无消息数据</div>
</div>
</div>
</div>
</div>
<script>
// --- Icons (SVG) ---
const ICONS = {
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`,
chevronUp: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>`,
info: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`,
arrival: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10l5 5 5-5"/><path d="M12 3v12"/></svg>`,
departure: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 14l-5-5-5 5"/><path d="M12 21V9"/></svg>`,
broadcast: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`
};
// --- State ---
let expandedTrains = new Set();
let stationCodeMap = {};
let broadcastingStations = new Set();
let messageLog = [];
const MAX_MESSAGES = 50;
// --- Clock ---
function updateClock() {
const now = new Date();
document.getElementById('clock').textContent = now.toLocaleTimeString();
}
setInterval(updateClock, 1000);
updateClock();
// --- WebSocket ---
const connStatus = document.getElementById('conn-status');
const connDot = document.getElementById('conn-dot');
let ws;
function connect() {
const wsProto = window.location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${wsProto}://${window.location.host}`);
ws.onopen = () => {
connStatus.textContent = '已连接';
connStatus.style.color = 'var(--success)';
connDot.className = 'status-dot dot-online';
ws.send(JSON.stringify({ type: 'console_connect' }));
};
ws.onclose = () => {
connStatus.textContent = '已断开 (重连中...)';
connStatus.style.color = 'var(--danger)';
connDot.className = 'status-dot dot-offline';
setTimeout(connect, 3000);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'init_state' || data.type === 'state_update') {
updateState(data);
if (data.logs && data.type === 'init_state') {
data.logs.forEach(l => addMessage(l));
}
} else if (data.type === 'log') {
addMessage(data.data);
} else if (data.type === 'stream_start') {
broadcastingStations.add(data.station);
updateStationDots();
} else if (data.type === 'stream_end') {
broadcastingStations.delete(data.station);
updateStationDots();
}
};
}
function addMessage(msg) {
const time = new Date().toLocaleTimeString();
let type = 'log-info';
let icon = ICONS.info;
let important = false;
if (msg.includes('Arrived') || msg.includes('arrival')) {
type = 'log-arrival';
icon = ICONS.arrival;
important = true;
} else if (msg.includes('Departed') || msg.includes('departure')) {
type = 'log-departure';
icon = ICONS.departure;
important = true;
} else if (msg.includes('Broadcast') || msg.includes('Stream')) {
type = 'log-broadcast';
icon = ICONS.broadcast;
}
messageLog.unshift({ time, content: msg, type, icon, important });
if (messageLog.length > MAX_MESSAGES) messageLog.pop();
renderMessages();
}
function renderMessages() {
const container = document.getElementById('message-list');
if (messageLog.length === 0) {
container.innerHTML = '<div style="padding:40px; text-align:center; color:var(--text-muted); font-style:italic;">暂无消息数据</div>';
return;
}
container.innerHTML = messageLog.map(m => `
<div class="log-item ${m.type} ${m.important ? 'log-important' : ''}">
<div class="log-icon">${m.icon}</div>
<div class="log-body">
<span class="log-time">${m.time}</span>
<span class="log-content">${m.content}</span>
</div>
</div>
`).join('');
}
function updateState(state) {
if (state.stations) {
const list = document.getElementById('station-list');
list.innerHTML = '';
const stList = Array.isArray(state.stations) ? state.stations : Object.values(state.stations);
document.getElementById('station-count').textContent = stList.length;
stList.forEach(st => {
stationCodeMap[st.name] = st.code;
});
if (stList.length === 0) {
list.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted);font-size:0.8rem">暂无活动车站</div>';
} else {
stList.forEach(st => {
const isOnline = (Date.now() - st.lastSeen) < 60000;
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 div = document.createElement('div');
div.className = 'status-item';
div.innerHTML = `
<div class="item-info">
<div class="item-title">
${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>' : ''}
</div>
<div class="item-sub">${st.code}${st.platform ? ` - 站台 ${st.platform}` : ''}</div>
</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>
`;
list.appendChild(div);
});
}
}
if (state.activeTrains) {
const list = document.getElementById('train-list');
list.innerHTML = '';
document.getElementById('train-count').textContent = state.activeTrains.length;
if (!state.activeTrains || state.activeTrains.length === 0) {
list.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted);font-size:0.8rem">暂无活跃列车</div>';
} else {
state.activeTrains.forEach(tr => {
const isExpanded = expandedTrains.has(tr.trainId);
const div = document.createElement('div');
div.className = 'status-item train-item';
div.onclick = () => toggleTrain(tr.trainId);
let detailsHtml = '';
if (tr.tripLogs && tr.tripLogs.length > 0) {
detailsHtml = tr.tripLogs.slice().reverse().map(log => {
const time = new Date(log.time).toLocaleTimeString();
const stCode = stationCodeMap[log.station] || log.station;
return `
<div class="trip-log">
<span>${time}</span>
<span style="color:var(--text-primary)">${stCode}</span>
<span style="color:${log.action === 'Arrival' ? 'var(--success)' : 'var(--warning)'}">${log.action === 'Arrival' ? '到达' : '出发'}</span>
</div>
`;
}).join('');
} else {
detailsHtml = '<div style="font-style:italic">暂无运行历史</div>';
}
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
const statusColor = tr.status === 'At Station' ? 'var(--success)' : 'var(--warning)';
div.innerHTML = `
<div style="width:100%">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div class="item-info">
<div class="item-title">${tr.trainId} <span style="font-size:0.7rem; color:var(--text-muted); font-weight:normal; margin-left:8px;">${tr.destination || '未知终点'}</span></div>
<div class="item-sub">${currentStCode} <span style="color:${statusColor}; margin-left:8px;">${tr.status === 'At Station' ? '在站' : '运行中'}</span></div>
</div>
<div style="color:var(--text-muted)">${isExpanded ? ICONS.chevronUp : ICONS.chevronDown}</div>
</div>
<div class="train-details ${isExpanded ? 'open' : ''}" id="details-${tr.trainId}">
<div style="margin-bottom:8px;font-weight:600;font-size:0.7rem;text-transform:uppercase;color:var(--text-muted)">运行日志 (Trip Logs)</div>
${detailsHtml}
</div>
</div>
`;
list.appendChild(div);
});
}
}
}
function toggleTrain(id) {
if (expandedTrains.has(id)) expandedTrains.delete(id);
else expandedTrains.add(id);
const details = document.getElementById(`details-${id}`);
if (details) {
details.classList.toggle('open');
}
}
function updateStationDots() {
const dots = document.querySelectorAll('[id^="station-dot-"]');
dots.forEach(dot => {
const name = dot.getAttribute('data-name');
const isOnline = dot.getAttribute('data-online') === 'true';
const isBroadcasting = broadcastingStations.has(name);
dot.className = 'status-dot ' + (isBroadcasting ? 'dot-broadcasting' : (isOnline ? 'dot-online' : 'dot-offline'));
});
}
connect();
</script>
</body>
</html>
+742
View File
@@ -0,0 +1,742 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>list.txt Manager</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-body: #09090b;
--bg-panel: #121214;
--bg-hover: #27272a;
--bg-border: #27272a;
--bg-input: #09090b;
--text-primary: #f4f4f5;
--text-muted: #a1a1aa;
--danger: #ef4444;
--success: #10b981;
--radius: 8px;
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--ring-color: rgba(255, 255, 255, 0.2);
}
* { box-sizing: border-box; }
body {
font-family: var(--font-ui);
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
color: var(--text-primary);
margin: 0;
padding: 30px 20px;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.layout-wrapper {
width: 100%;
max-width: 980px;
display: flex;
flex-direction: column;
gap: 18px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 14px;
border-bottom: 1px solid var(--bg-border);
gap: 12px;
flex-wrap: wrap;
}
.header h2 {
margin: 0;
font-weight: 600;
letter-spacing: -0.025em;
background: linear-gradient(to right, #fff, #a1a1aa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.panel {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
}
.panel-subtitle {
font-size: 0.85rem;
color: var(--text-muted);
padding-bottom: 12px;
border-bottom: 1px solid var(--bg-border);
}
.toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.btn {
background: transparent;
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 500;
user-select: none;
text-decoration: none;
}
.btn:hover:not(:disabled) { background: var(--bg-hover); }
.btn-primary {
background: var(--text-primary);
color: var(--bg-body);
border-color: var(--text-primary);
font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
background: #d4d4d8;
border-color: #d4d4d8;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.list-container {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 65vh;
overflow-y: auto;
padding-right: 4px;
}
.list-item {
display: grid;
grid-template-columns: 110px 1fr auto;
gap: 14px;
align-items: center;
background: var(--bg-input);
padding: 12px 14px;
border: 1px solid var(--bg-border);
border-radius: 6px;
transition: border-color 0.2s;
}
.list-item:hover { border-color: #52525b; }
.code {
font-family: var(--font-mono);
font-weight: 600;
white-space: nowrap;
}
.tag {
display: inline-flex;
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-hover);
font-size: 0.8rem;
margin-right: 4px;
margin-bottom: 4px;
font-family: var(--font-mono);
}
.btn-icon {
padding: 8px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover { color: var(--text-primary); background: var(--bg-hover); }
.btn-icon.danger:hover { color: var(--danger); background: rgba(239, 68, 68, 0.1); }
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
backdrop-filter: blur(6px);
}
.modal-overlay.active {
opacity: 1;
pointer-events: auto;
}
.modal {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: 12px;
width: 92%;
max-width: 540px;
display: flex;
flex-direction: column;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.7);
}
.modal-header {
padding: 18px 20px;
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
font-size: 1.05rem;
}
.modal-body {
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-footer {
padding: 14px 20px;
border-top: 1px solid var(--bg-border);
display: flex;
justify-content: flex-end;
gap: 10px;
background: rgba(0,0,0,0.2);
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.form-group { display: flex; flex-direction: column; gap: 8px; }
label { font-size: 0.875rem; font-weight: 500; }
input[type="text"] {
background: var(--bg-input);
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 6px;
font-family: var(--font-mono);
font-size: 0.95rem;
outline: none;
transition: all 0.2s;
}
input[type="text"]:focus {
border-color: #52525b;
box-shadow: 0 0 0 3px var(--ring-color);
}
.edit-files-list {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--bg-input);
border: 1px solid var(--bg-border);
border-radius: 6px;
padding: 12px;
min-height: 120px;
max-height: 260px;
overflow-y: auto;
}
.edit-file-item {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-panel);
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--bg-border);
transition: all 0.2s;
gap: 10px;
}
.edit-file-name {
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--text-primary);
word-break: break-all;
}
.edit-file-actions { display: flex; gap: 6px; }
.custom-select-container {
position: relative;
width: 100%;
font-family: var(--font-ui);
font-size: 0.95rem;
user-select: none;
}
.custom-select-trigger {
background: var(--bg-input);
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 6px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
}
.custom-select-trigger.active {
border-color: #52525b;
box-shadow: 0 0 0 3px var(--ring-color);
}
.custom-select-options {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: 6px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
z-index: 150;
max-height: 240px;
overflow-y: auto;
display: none;
flex-direction: column;
padding: 4px;
}
.custom-select-options.active { display: flex; }
.custom-select-option {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
color: var(--text-primary);
transition: background 0.1s;
font-family: var(--font-mono);
font-size: 0.85rem;
}
.custom-select-option:hover { background: var(--bg-hover); }
.custom-select-option.selected { background: var(--bg-hover); font-weight: 500; }
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
display: flex;
flex-direction: column;
gap: 12px;
}
.toast {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
padding: 14px 18px;
border-radius: 8px;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
display: flex;
align-items: center;
gap: 12px;
min-width: 280px;
animation: slideIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideIn {
from { transform: translateX(120%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg-input); border-radius: 4px; }
::-webkit-scrollbar-thumb { background: #52525b; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #71717a; }
* { scrollbar-width: thin; scrollbar-color: #52525b var(--bg-input); }
</style>
</head>
<body>
<div class="layout-wrapper">
<div class="header">
<h2>list.txt 管理</h2>
<div class="header-actions">
<a class="btn" href="/">控制台</a>
<a class="btn" href="/generator.html">Generator</a>
</div>
</div>
<div class="panel">
<div class="panel-subtitle">对 uploads/list.txt 进行增删改;每条记录格式:站号==音频文件1,音频文件2,... (支持多个站号用 / 分隔)</div>
<div class="toolbar">
<button class="btn" onclick="openAddModal()">添加</button>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button class="btn" onclick="reloadAll()">刷新</button>
<button class="btn btn-primary" id="btn-save" onclick="saveList()">保存到服务器</button>
</div>
</div>
<div class="list-container" id="list-container">
<div style="text-align:center; padding: 40px; color:var(--text-muted)">Loading...</div>
</div>
</div>
</div>
<div class="modal-overlay" id="edit-modal">
<div class="modal">
<div class="modal-header">
<span id="modal-title">Edit</span>
<button class="btn-icon" onclick="closeModal()" aria-label="Close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>站号</label>
<input type="text" id="edit-code" placeholder="例如:01-01 / 01-01/01-02" />
</div>
<div class="form-group">
<label>播放序列(从上到下)</label>
<div class="edit-files-list" id="edit-files-list"></div>
</div>
<div class="form-group">
<label>添加音频文件</label>
<div style="display:flex; gap:10px; align-items:center;">
<div class="custom-select-container" id="custom-file-select">
<div class="custom-select-trigger" onclick="toggleCustomSelect()">
<span id="custom-select-label">-- Select an audio file --</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a1a1aa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</div>
<div class="custom-select-options" id="custom-select-options"></div>
</div>
<button class="btn" onclick="addFileToSequence()">添加</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeModal()">取消</button>
<button class="btn btn-primary" onclick="confirmEdit()">确定</button>
</div>
</div>
</div>
<div class="toast-container" id="toast-container"></div>
<script>
const ICONS = {
up: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"></polyline></svg>`,
down: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
trash: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>`,
edit: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`
};
let listData = [];
let availableFiles = [];
let editingIndex = -1;
let currentSequence = [];
let selectedFileValue = '';
function showToast(title, message) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = 'toast';
toast.innerHTML = `
<div>
<div style="font-weight:600; font-size:0.9rem; margin-bottom:2px">${title}</div>
<div style="font-size:0.8rem; color:var(--text-muted)">${message}</div>
</div>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 250);
}, 2500);
}
function toggleCustomSelect() {
const trigger = document.querySelector('#custom-file-select .custom-select-trigger');
const options = document.getElementById('custom-select-options');
if (!trigger || !options) return;
const next = !options.classList.contains('active');
trigger.classList.toggle('active', next);
options.classList.toggle('active', next);
}
function closeCustomSelect() {
const trigger = document.querySelector('#custom-file-select .custom-select-trigger');
const options = document.getElementById('custom-select-options');
if (!trigger || !options) return;
trigger.classList.remove('active');
options.classList.remove('active');
}
function selectCustomOption(value, label) {
selectedFileValue = value || '';
const labelEl = document.getElementById('custom-select-label');
if (labelEl) labelEl.textContent = label || '-- Select an audio file --';
const optionsContainer = document.getElementById('custom-select-options');
if (optionsContainer) {
Array.from(optionsContainer.querySelectorAll('.custom-select-option')).forEach(el => {
el.classList.toggle('selected', (el.getAttribute('data-value') || '') === selectedFileValue);
});
}
closeCustomSelect();
}
document.addEventListener('click', (e) => {
const container = document.getElementById('custom-file-select');
if (!container) return;
if (!container.contains(e.target)) closeCustomSelect();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeCustomSelect();
});
async function fetchAvailableFiles() {
const res = await fetch('/api/files');
const data = await res.json();
availableFiles = (data.files || [])
.filter(f => f.type === 'station' || f.type === 'general')
.map(f => f.name)
.sort();
populateFileSelect();
}
function populateFileSelect() {
const optionsContainer = document.getElementById('custom-select-options');
if (!optionsContainer) return;
let html = `<div class="custom-select-option ${selectedFileValue === '' ? 'selected' : ''}" data-value="" onclick="selectCustomOption('', '-- Select an audio file --')">-- Select an audio file --</div>`;
availableFiles.forEach(f => {
const isSelected = selectedFileValue === f ? 'selected' : '';
html += `<div class="custom-select-option ${isSelected}" data-value="${f}" onclick="selectCustomOption('${f}', '${f}')">${f}</div>`;
});
optionsContainer.innerHTML = html;
}
async function loadList() {
const res = await fetch('/api/files/content?path=uploads/list.txt');
const data = await res.json();
listData = [];
if (data.success && data.content) {
const lines = String(data.content).split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^([^=]+)==(.+)$/);
if (match) {
listData.push({ code: match[1], files: match[2].split(',').filter(Boolean) });
}
}
}
renderList();
}
function renderList() {
const container = document.getElementById('list-container');
container.innerHTML = '';
listData.sort((a, b) => String(a.code).localeCompare(String(b.code)));
if (listData.length === 0) {
container.innerHTML = '<div style="text-align:center; padding: 40px; color:var(--text-muted)">No entries</div>';
return;
}
listData.forEach((item, index) => {
const div = document.createElement('div');
div.className = 'list-item';
const tagsHtml = (item.files || []).map(f => `<span class="tag">${f}</span>`).join(' ');
div.innerHTML = `
<div class="code">${item.code}</div>
<div>${tagsHtml || '<span style="color:var(--text-muted); font-style: italic;">No files</span>'}</div>
<div style="display:flex; gap:6px;">
<button class="btn-icon" onclick="openEditModal(${index})" aria-label="Edit">${ICONS.edit}</button>
<button class="btn-icon danger" onclick="removeListItem(${index})" aria-label="Delete">${ICONS.trash}</button>
</div>
`;
container.appendChild(div);
});
}
function removeListItem(index) {
const code = listData[index]?.code;
if (!code) return;
if (!confirm(`删除 ${code} ?`)) return;
listData.splice(index, 1);
renderList();
}
function openAddModal() {
editingIndex = -1;
currentSequence = [];
document.getElementById('modal-title').textContent = '添加站号配置';
document.getElementById('edit-code').value = '';
renderSequence();
selectCustomOption('', '-- Select an audio file --');
document.getElementById('edit-modal').classList.add('active');
}
function openEditModal(index) {
editingIndex = index;
const item = listData[index];
currentSequence = [...(item?.files || [])];
document.getElementById('modal-title').textContent = `编辑:${item?.code || ''}`;
document.getElementById('edit-code').value = item?.code || '';
renderSequence();
selectCustomOption('', '-- Select an audio file --');
document.getElementById('edit-modal').classList.add('active');
}
function closeModal() {
closeCustomSelect();
document.getElementById('edit-modal').classList.remove('active');
}
function renderSequence() {
const list = document.getElementById('edit-files-list');
list.innerHTML = '';
if (currentSequence.length === 0) {
list.innerHTML = '<div style="color:var(--text-muted); font-size:0.85rem; text-align:center; padding:10px;">No files added yet</div>';
return;
}
currentSequence.forEach((file, idx) => {
const div = document.createElement('div');
div.className = 'edit-file-item';
div.innerHTML = `
<div class="edit-file-name">${file}</div>
<div class="edit-file-actions">
<button class="btn-icon" onclick="moveFile(${idx}, -1)" ${idx === 0 ? 'disabled style="opacity:0.35"' : ''} aria-label="Up">${ICONS.up}</button>
<button class="btn-icon" onclick="moveFile(${idx}, 1)" ${idx === currentSequence.length - 1 ? 'disabled style="opacity:0.35"' : ''} aria-label="Down">${ICONS.down}</button>
<button class="btn-icon danger" onclick="removeFileFromSequence(${idx})" aria-label="Remove">${ICONS.trash}</button>
</div>
`;
list.appendChild(div);
});
}
function addFileToSequence() {
const file = selectedFileValue;
if (!file) return;
currentSequence.push(file);
renderSequence();
selectCustomOption('', '-- Select an audio file --');
}
function removeFileFromSequence(idx) {
currentSequence.splice(idx, 1);
renderSequence();
}
function moveFile(idx, dir) {
if (idx + dir < 0 || idx + dir >= currentSequence.length) return;
const temp = currentSequence[idx];
currentSequence[idx] = currentSequence[idx + dir];
currentSequence[idx + dir] = temp;
renderSequence();
}
function confirmEdit() {
const code = document.getElementById('edit-code').value.trim();
if (!code) {
alert('站号不能为空');
return;
}
if (editingIndex === -1) {
if (listData.some(item => item.code === code)) {
alert(`站号 ${code} 已存在`);
return;
}
listData.push({ code, files: [...currentSequence] });
} else {
listData[editingIndex].code = code;
listData[editingIndex].files = [...currentSequence];
}
renderList();
closeModal();
}
async function saveList() {
const btn = document.getElementById('btn-save');
btn.disabled = true;
btn.textContent = 'Saving...';
const content = listData
.filter(item => item.code)
.map(item => `${item.code}==${(item.files || []).join(',')}`)
.join('\n');
try {
const res = await fetch('/api/files/content', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: 'uploads/list.txt', content: content + '\n' })
});
const data = await res.json();
if (!data.success) throw new Error(data.message || 'Save failed');
showToast('Success', 'list.txt saved');
} catch (e) {
showToast('Error', e?.message || String(e));
} finally {
btn.disabled = false;
btn.textContent = '保存到服务器';
}
}
async function reloadAll() {
try {
await fetchAvailableFiles();
await loadList();
showToast('OK', 'Reloaded');
} catch (e) {
showToast('Error', e?.message || String(e));
}
}
async function init() {
try {
await fetchAvailableFiles();
await loadList();
} catch (e) {
showToast('Error', e?.message || String(e));
}
}
init();
</script>
</body>
</html>
+436
View File
@@ -0,0 +1,436 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>音乐播放</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-body: #09090b;
--bg-panel: #18181b;
--bg-hover: #27272a;
--bg-border: #27272a;
--text-primary: #e4e4e7;
--text-muted: #71717a;
--accent: #fafafa;
--danger: #ef4444;
--radius: 6px;
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 24px;
background: var(--bg-body);
color: var(--text-primary);
font-family: var(--font-ui);
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.title {
font-size: 1.1rem;
font-weight: 600;
}
.btn {
border: 1px solid var(--bg-border);
background: transparent;
color: var(--text-primary);
padding: 8px 10px;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.85rem;
}
.btn:hover { background: var(--bg-hover); }
.btn-primary {
background: var(--accent);
color: #09090b;
border-color: var(--accent);
}
.btn-primary:hover { filter: brightness(0.92); background: var(--accent); }
.btn-danger {
background: var(--danger);
border-color: var(--danger);
color: white;
}
.btn-danger:hover { filter: brightness(0.92); background: var(--danger); }
.grid {
display: grid;
grid-template-columns: 1fr 380px;
gap: 16px;
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
padding: 14px;
overflow: hidden;
}
.card-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.panel h3 {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid var(--bg-border);
background: #0f0f12;
color: var(--text-muted);
font-size: 0.78rem;
font-family: var(--font-mono);
}
label {
display: block;
margin: 10px 0 6px 0;
font-size: 0.85rem;
color: var(--text-muted);
}
select, input[type="text"] {
width: 100%;
padding: 10px;
border-radius: var(--radius);
border: 1px solid var(--bg-border);
background: #0f0f12;
color: var(--text-primary);
font-family: var(--font-ui);
font-size: 0.9rem;
outline: none;
}
.row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
margin-top: 10px;
}
.status {
margin-top: 10px;
padding: 10px;
border-radius: var(--radius);
border: 1px solid var(--bg-border);
background: #0f0f12;
font-family: var(--font-mono);
font-size: 0.8rem;
white-space: pre-wrap;
}
.stations {
max-height: 420px;
overflow: auto;
border: 1px solid var(--bg-border);
border-radius: var(--radius);
background: #0f0f12;
}
.station-toolbar {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
margin-top: 10px;
}
.station-item {
display: flex;
gap: 10px;
padding: 8px 10px;
border-bottom: 1px solid var(--bg-border);
align-items: center;
}
.station-item:last-child { border-bottom: none; }
.station-name { flex: 1; }
.station-meta {
font-family: var(--font-mono);
color: var(--text-muted);
font-size: 0.75rem;
}
@media (max-width: 980px) {
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="topbar">
<div class="title">音乐播放</div>
<div class="row" style="margin-top:0">
<a class="btn" href="/index.html" style="text-decoration:none; display:inline-flex; align-items:center;">返回控制台</a>
</div>
</div>
<div class="grid">
<div class="panel">
<div class="card-title">
<h3>控制</h3>
<span class="badge" id="track_badge">未选择</span>
</div>
<label for="track_select">选择音乐(来自 uploads/audio/general)</label>
<div class="row" style="margin-top:0">
<select id="track_select" onchange="onTrackChange()" style="flex:1; min-width: 200px;"></select>
<button class="btn" onclick="loadTracks()">刷新</button>
<button class="btn btn-primary" onclick="document.getElementById('local_file_input').click()">从本地选择</button>
<input type="file" id="local_file_input" accept=".wav,.dfpwm" style="display:none" onchange="uploadLocalFile(this)" />
</div>
<div class="row">
<label style="margin:0; display:flex; align-items:center; gap:8px; color:var(--text-muted); font-size:0.85rem;">
<input type="checkbox" id="loop_chk" />
循环播放
</label>
</div>
<div class="row">
<button class="btn btn-primary" onclick="startMusic()">开始播放</button>
<button class="btn btn-danger" onclick="stopMusic()">停止播放</button>
</div>
<div class="status" id="status_box">就绪</div>
</div>
<div class="panel">
<div class="card-title">
<h3>目标站台</h3>
<span class="badge" id="sel_badge">已选 0</span>
</div>
<div class="station-toolbar">
<input type="text" id="station_filter" placeholder="搜索站台…" oninput="applyStationFilter()" />
<button class="btn" onclick="reloadStations()">刷新</button>
</div>
<div class="row">
<button class="btn" onclick="selectAll(true)">全选</button>
<button class="btn" onclick="selectAll(false)">全不选</button>
</div>
<div class="stations" id="stations_box" style="margin-top:10px;"></div>
</div>
</div>
<script>
function setStatus(msg) {
document.getElementById("status_box").textContent = String(msg || "");
}
function escapeHtml(str) {
return String(str || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function getSelectedStations() {
const nodes = document.querySelectorAll('input[data-station-name="1"]:checked');
const names = [];
for (const n of nodes) names.push(n.getAttribute("data-name"));
return names;
}
function updateSelectedBadge() {
const n = getSelectedStations().length;
document.getElementById("sel_badge").textContent = `已选 ${n}`;
}
function applyStationFilter() {
const keyword = String(document.getElementById("station_filter").value || "").trim().toLowerCase();
const rows = document.querySelectorAll("#stations_box .station-item");
for (const row of rows) {
const nameEl = row.querySelector(".station-name");
const metaEl = row.querySelector(".station-meta");
const text = `${nameEl ? nameEl.textContent : ""} ${metaEl ? metaEl.textContent : ""}`.toLowerCase();
row.style.display = keyword === "" || text.includes(keyword) ? "" : "none";
}
}
function selectAll(on) {
const nodes = document.querySelectorAll('input[data-station-name="1"]');
for (const n of nodes) n.checked = !!on;
updateSelectedBadge();
}
async function reloadStations() {
const box = document.getElementById("stations_box");
box.innerHTML = "";
setStatus("加载站台列表中…");
try {
const resp = await fetch("/api/stations");
const data = await resp.json();
if (!data || !data.success) throw new Error(data?.message || "加载失败");
const stations = Array.isArray(data.stations) ? data.stations : [];
if (stations.length === 0) {
box.innerHTML = '<div class="station-item" style="color:var(--text-muted); font-style:italic;">暂无在线/已注册站台</div>';
setStatus("站台列表为空");
return;
}
for (const st of stations) {
const name = st.name || "";
const code = st.code || "";
const lastSeen = st.lastSeen ? new Date(st.lastSeen).toLocaleString() : "";
const row = document.createElement("div");
row.className = "station-item";
row.innerHTML = `
<input type="checkbox" data-station-name="1" data-name="${escapeHtml(name)}" onchange="updateSelectedBadge()" />
<div class="station-name">${escapeHtml(name)}</div>
<div class="station-meta">${escapeHtml(code)}${lastSeen ? " · " + escapeHtml(lastSeen) : ""}</div>
`;
box.appendChild(row);
}
setStatus("站台列表已加载");
updateSelectedBadge();
applyStationFilter();
} catch (e) {
setStatus("加载站台失败: " + (e?.message || String(e)));
}
}
function onTrackChange() {
const sel = document.getElementById("track_select");
const v = String(sel.value || "").trim();
document.getElementById("track_badge").textContent = v ? v : "未选择";
}
async function loadTracks() {
const sel = document.getElementById("track_select");
sel.innerHTML = "";
setStatus("加载音乐列表中…");
try {
const resp = await fetch("/api/music/tracks");
const data = await resp.json();
if (!data || !data.success) throw new Error(data?.message || "加载失败");
const tracks = Array.isArray(data.tracks) ? data.tracks : [];
if (tracks.length === 0) {
sel.innerHTML = '<option value="">(无可用音频)</option>';
onTrackChange();
setStatus("没有可用音频(请上传到 uploads/audio/general)");
return;
}
sel.appendChild(new Option("请选择…", ""));
for (const t of tracks) sel.appendChild(new Option(t, t));
onTrackChange();
setStatus(`音乐列表已加载:${tracks.length} 个`);
} catch (e) {
sel.innerHTML = '<option value="">(加载失败)</option>';
onTrackChange();
setStatus("加载音乐列表失败: " + (e?.message || String(e)));
}
}
async function uploadLocalFile(input) {
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append("generalAudioFiles", file);
setStatus(`正在上传 ${file.name}…`);
try {
const resp = await fetch("/api/upload/general-audio", {
method: "POST",
body: formData
});
if (!resp.ok) {
const text = await resp.text();
let msg = `服务器返回错误 (${resp.status})`;
try {
const errData = JSON.parse(text);
if (errData.message) msg += `: ${errData.message}`;
} catch {
msg += `: ${text.slice(0, 100)}${text.length > 100 ? "..." : ""}`;
}
throw new Error(msg);
}
const data = await resp.json();
if (!data || !data.success) throw new Error(data?.message || "上传失败");
setStatus(`文件 ${file.name} 上传成功,正在刷新列表…`);
await loadTracks();
// Try to select the uploaded file
const baseName = String(file.name || "").replace(/\.(wav|dfpwm)$/i, "").trim();
const sel = document.getElementById("track_select");
for (let i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === baseName) {
sel.selectedIndex = i;
onTrackChange();
break;
}
}
setStatus(`已选择上传的文件: ${baseName}`);
} catch (e) {
setStatus("上传失败: " + (e?.message || String(e)));
} finally {
input.value = ""; // Clear for next selection
}
}
async function startMusic() {
const stations = getSelectedStations();
const track = String(document.getElementById("track_select").value || "").trim();
const loop = Boolean(document.getElementById("loop_chk").checked);
if (!track) {
setStatus("请选择音乐");
return;
}
if (stations.length === 0) {
setStatus("请至少勾选一个站台");
return;
}
setStatus("开始播放中…");
try {
const resp = await fetch("/api/music/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stations, track, loop })
});
const data = await resp.json();
if (!data || !data.success) throw new Error(data?.message || "启动失败");
setStatus(`已下发播放:成功=${data.sent || 0} 失败=${data.failed || 0}`);
} catch (e) {
setStatus("启动失败: " + (e?.message || String(e)));
}
}
async function stopMusic() {
const stations = getSelectedStations();
if (stations.length === 0) {
setStatus("请至少勾选一个站台");
return;
}
setStatus("停止中…");
try {
const resp = await fetch("/api/music/stop", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stations })
});
const data = await resp.json();
if (!data || !data.success) throw new Error(data?.message || "停止失败");
setStatus(`已停止:${data.stopped || 0} 个站台`);
} catch (e) {
setStatus("停止失败: " + (e?.message || String(e)));
}
}
reloadStations();
loadTracks();
</script>
</body>
</html>
+260
View File
@@ -0,0 +1,260 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Station Broadcast Streams</title>
<style>
:root {
--bg: #0b1220;
--panel: #111a2e;
--border: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.92);
--muted: rgba(255,255,255,0.65);
--good: #22c55e;
--warn: #f59e0b;
--bad: #ef4444;
--link: #60a5fa;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
background: var(--bg);
color: var(--text);
}
header {
padding: 18px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
header h1 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
header .meta {
font-size: 12px;
color: var(--muted);
display: flex;
gap: 14px;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
}
main { padding: 16px 20px 28px; }
.toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
}
.toolbar input {
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 10px;
border-radius: 8px;
width: 280px;
max-width: 100%;
outline: none;
}
.toolbar button, .toolbar a {
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
text-decoration: none;
font-size: 12px;
}
.toolbar button:hover, .toolbar a:hover { border-color: rgba(96,165,250,0.6); }
table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
th, td {
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,0.06);
font-size: 12px;
vertical-align: top;
}
th {
text-align: left;
color: var(--muted);
font-weight: 600;
background: rgba(255,255,255,0.02);
position: sticky;
top: 0;
z-index: 1;
}
tr:hover td { background: rgba(96,165,250,0.06); }
.mono { font-family: var(--mono); }
.status {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--muted);
box-shadow: 0 0 0 2px rgba(255,255,255,0.08) inset;
}
.dot.idle { background: var(--muted); }
.dot.pushing { background: var(--good); box-shadow: 0 0 12px rgba(34,197,94,0.35); }
.dot.error { background: var(--bad); box-shadow: 0 0 12px rgba(239,68,68,0.35); }
a.link { color: var(--link); text-decoration: none; }
a.link:hover { text-decoration: underline; }
.pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 11px;
color: var(--muted);
white-space: nowrap;
}
.err {
color: rgba(255,255,255,0.8);
max-width: 520px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
</head>
<body>
<header>
<h1>Station Broadcast Streams</h1>
<div class="meta">
<span id="meta-mode"></span>
<span id="meta-base"></span>
<span id="meta-updated"></span>
</div>
</header>
<main>
<div class="toolbar">
<input id="filter" placeholder="筛选:站名 / 站号 / 流名" />
<button id="btn-refresh">刷新</button>
<a href="/" target="_blank" rel="noreferrer">控制台</a>
</div>
<table>
<thead>
<tr>
<th style="width: 120px;">流名</th>
<th style="width: 220px;">车站</th>
<th style="width: 110px;">站号</th>
<th>播放地址</th>
<th style="width: 150px;">状态</th>
<th style="width: 210px;">最近时间</th>
<th style="width: 340px;">错误</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</main>
<script>
const tbody = document.getElementById('tbody');
const filterInput = document.getElementById('filter');
const metaMode = document.getElementById('meta-mode');
const metaBase = document.getElementById('meta-base');
const metaUpdated = document.getElementById('meta-updated');
let lastData = null;
function fmtTime(ts) {
if (!ts) return '-';
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '-';
return d.toLocaleString();
}
function pickDot(status, lastError) {
if (status === 'pushing' || status === 'building') return 'pushing';
if (lastError) return 'error';
return 'idle';
}
function escapeHtml(s) {
return String(s ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function render(data) {
lastData = data;
metaMode.textContent = `Mode: ${data.streamMode || '-'}`;
metaBase.textContent = `Base: ${data.publicBase || '-'}`;
metaUpdated.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
const q = filterInput.value.trim().toLowerCase();
const rows = (data.streams || []).filter(s => {
if (!q) return true;
return String(s.streamName || '').toLowerCase().includes(q)
|| String(s.stationName || '').toLowerCase().includes(q)
|| String(s.stationCode || '').toLowerCase().includes(q)
|| String(s.hlsUrl || '').toLowerCase().includes(q);
});
tbody.innerHTML = rows.map(s => {
const dot = pickDot(s.status, s.lastError);
const lastTime = s.lastStartAt || s.lastEndAt;
return `
<tr>
<td class="mono">${escapeHtml(s.streamName || '-')}</td>
<td>${escapeHtml(s.stationName || '-')}</td>
<td class="mono">${escapeHtml(s.stationCode || '-')}</td>
<td class="mono">
<a class="link" href="${escapeHtml(s.hlsUrl || '#')}" target="_blank" rel="noreferrer">${escapeHtml(s.hlsUrl || '-')}</a>
</td>
<td>
<span class="status">
<span class="dot ${dot}"></span>
<span class="pill">${escapeHtml(s.status || 'idle')}</span>
</span>
</td>
<td>
<div>Start: ${escapeHtml(fmtTime(s.lastStartAt))}</div>
<div>End: ${escapeHtml(fmtTime(s.lastEndAt))}</div>
</td>
<td class="mono err" title="${escapeHtml(s.lastError || '')}">${escapeHtml(s.lastError || '-')}</td>
</tr>
`;
}).join('');
}
async function refresh() {
try {
const res = await fetch('/api/streams', { cache: 'no-store' });
const data = await res.json();
render(data);
} catch (e) {
tbody.innerHTML = `<tr><td colspan="7" class="mono">Failed to load /api/streams: ${escapeHtml(e?.message || e)}</td></tr>`;
}
}
document.getElementById('btn-refresh').addEventListener('click', refresh);
filterInput.addEventListener('input', () => { if (lastData) render(lastData); });
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>
+396
View File
@@ -0,0 +1,396 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stream Manager</title>
<style>
:root {
--bg: #0b1220;
--panel: #111a2e;
--border: rgba(255,255,255,0.08);
--text: rgba(255,255,255,0.92);
--muted: rgba(255,255,255,0.65);
--good: #22c55e;
--warn: #f59e0b;
--bad: #ef4444;
--link: #60a5fa;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
background: var(--bg);
color: var(--text);
}
header {
padding: 18px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
header h1 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
header .meta {
font-size: 12px;
color: var(--muted);
display: flex;
gap: 14px;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
}
main { padding: 16px 20px 28px; }
.toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
}
.toolbar input {
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 10px;
border-radius: 8px;
width: 320px;
max-width: 100%;
outline: none;
}
.toolbar button, .toolbar a {
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
text-decoration: none;
font-size: 12px;
}
.toolbar button:hover, .toolbar a:hover { border-color: rgba(96,165,250,0.6); }
table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
th, td {
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,0.06);
font-size: 12px;
vertical-align: top;
}
th {
text-align: left;
color: var(--muted);
font-weight: 600;
background: rgba(255,255,255,0.02);
position: sticky;
top: 0;
z-index: 1;
}
tr:hover td { background: rgba(96,165,250,0.06); }
.mono { font-family: var(--mono); }
a.link { color: var(--link); text-decoration: none; }
a.link:hover { text-decoration: underline; }
.pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 11px;
color: var(--muted);
white-space: nowrap;
}
.btn-danger { border-color: rgba(239,68,68,0.4); color: rgba(239,68,68,0.95); }
.btn-danger:hover { border-color: rgba(239,68,68,0.9); }
.modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.65);
display: none;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 10;
}
.modal.open { display: flex; }
.modal-card {
width: 100%;
max-width: 520px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.modal-header {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title { font-weight: 600; font-size: 14px; }
.modal-body { padding: 14px; display: flex; flex-direction: column; gap: 10px; }
.modal-body label { font-size: 12px; color: var(--muted); }
.modal-body input {
background: rgba(0,0,0,0.25);
border: 1px solid var(--border);
color: var(--text);
padding: 10px 10px;
border-radius: 8px;
outline: none;
font-family: var(--mono);
}
.modal-footer {
padding: 12px 14px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
</head>
<body>
<header>
<h1>Stream Manager</h1>
<div class="meta">
<span id="meta-base"></span>
<span id="meta-updated"></span>
</div>
</header>
<main>
<div class="toolbar">
<input id="filter" placeholder="筛选:站名 / 站号 / 流名" />
<button id="btn-add">新增站点</button>
<button id="btn-refresh">刷新</button>
<a href="/" target="_blank" rel="noreferrer">控制台</a>
</div>
<table>
<thead>
<tr>
<th style="width: 150px;">流名</th>
<th style="width: 260px;">车站</th>
<th style="width: 140px;">站号</th>
<th>播放地址(m3u8)</th>
<th style="width: 180px;">操作</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</main>
<div class="modal" id="modal">
<div class="modal-card">
<div class="modal-header">
<div class="modal-title" id="modal-title">站点</div>
<button id="modal-close">关闭</button>
</div>
<div class="modal-body">
<div style="display:flex; flex-direction:column; gap:6px;">
<label>站名(唯一键)</label>
<input id="input-name" placeholder="例如:HC-02 站台 / Tokyo Station" />
</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<label>站号(会影响流名)</label>
<input id="input-code" placeholder="例如:HC-02_2" />
</div>
<div style="display:flex; flex-direction:column; gap:6px;">
<label>重命名(可选,仅编辑时生效)</label>
<input id="input-newname" placeholder="不填则不改站名" />
</div>
</div>
<div class="modal-footer">
<button id="btn-cancel">取消</button>
<button id="btn-save">保存</button>
</div>
</div>
</div>
<script>
const tbody = document.getElementById('tbody');
const filterInput = document.getElementById('filter');
const metaBase = document.getElementById('meta-base');
const metaUpdated = document.getElementById('meta-updated');
const modal = document.getElementById('modal');
const modalTitle = document.getElementById('modal-title');
const inputName = document.getElementById('input-name');
const inputCode = document.getElementById('input-code');
const inputNewName = document.getElementById('input-newname');
let mode = 'create';
let editKey = '';
let lastData = null;
function escapeHtml(s) {
return String(s ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function openModal(nextMode, key, row) {
mode = nextMode;
editKey = key || '';
if (mode === 'create') {
modalTitle.textContent = '新增站点';
inputName.disabled = false;
inputName.value = '';
inputCode.value = '';
inputNewName.value = '';
} else {
modalTitle.textContent = '编辑站点';
inputName.disabled = true;
inputName.value = row?.stationName || '';
inputCode.value = row?.stationCode || '';
inputNewName.value = '';
}
modal.classList.add('open');
}
function closeModal() {
modal.classList.remove('open');
}
function render(data) {
lastData = data;
metaBase.textContent = `Base: ${data.publicBase || '-'}`;
metaUpdated.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
const q = filterInput.value.trim().toLowerCase();
const rows = (data.streams || []).filter(s => {
if (!q) return true;
return String(s.streamName || '').toLowerCase().includes(q)
|| String(s.stationName || '').toLowerCase().includes(q)
|| String(s.stationCode || '').toLowerCase().includes(q)
|| String(s.hlsUrl || '').toLowerCase().includes(q);
});
tbody.innerHTML = rows.map(s => {
const hasStation = !!s.stationName;
const actions = hasStation
? `
<button onclick="editStation('${escapeHtml(s.stationName)}')">编辑</button>
<button class="btn-danger" onclick="deleteStation('${escapeHtml(s.stationName)}')">删除站点</button>
`
: `
<button class="btn-danger" onclick="deleteStream('${escapeHtml(s.streamName)}')">清理流</button>
`;
return `
<tr>
<td class="mono">${escapeHtml(s.streamName || '-')}</td>
<td>${escapeHtml(s.stationName || '-')}</td>
<td class="mono">${escapeHtml(s.stationCode || '-')}</td>
<td class="mono">
<a class="link" href="${escapeHtml(s.hlsUrl || '#')}" target="_blank" rel="noreferrer">${escapeHtml(s.hlsUrl || '-')}</a>
</td>
<td style="white-space:nowrap">${actions}</td>
</tr>
`;
}).join('');
}
async function refresh() {
const res = await fetch('/api/streams', { cache: 'no-store' });
const data = await res.json();
render(data);
}
async function editStation(stationName) {
const row = (lastData?.streams || []).find(s => s.stationName === stationName);
openModal('edit', stationName, row);
}
async function deleteStation(stationName) {
if (!confirm(`删除站点:${stationName} ?`)) return;
await fetch(`/api/stations/${encodeURIComponent(stationName)}`, { method: 'DELETE' });
await refresh();
}
async function deleteStream(streamName) {
if (!confirm(`清理流状态:${streamName} ?`)) return;
await fetch(`/api/streams/${encodeURIComponent(streamName)}`, { method: 'DELETE' });
await refresh();
}
async function saveModal() {
const name = inputName.value.trim();
const code = inputCode.value.trim();
const newName = inputNewName.value.trim();
if (mode === 'create') {
if (!name) { alert('站名不能为空'); return; }
const res = await fetch('/api/stations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, code })
});
const data = await res.json();
if (!data.success) { alert(data.message || '保存失败'); return; }
} else {
const res = await fetch(`/api/stations/${encodeURIComponent(editKey)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, newName: newName || undefined })
});
const data = await res.json();
if (!data.success) { alert(data.message || '保存失败'); return; }
}
closeModal();
await refresh();
}
document.getElementById('btn-add').addEventListener('click', () => openModal('create'));
document.getElementById('btn-refresh').addEventListener('click', refresh);
filterInput.addEventListener('input', () => { if (lastData) render(lastData); });
document.getElementById('modal-close').addEventListener('click', closeModal);
document.getElementById('btn-cancel').addEventListener('click', closeModal);
document.getElementById('btn-save').addEventListener('click', saveModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>
+388
View File
@@ -0,0 +1,388 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>临时广播</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-body: #09090b;
--bg-panel: #18181b;
--bg-hover: #27272a;
--bg-border: #27272a;
--text-primary: #e4e4e7;
--text-muted: #71717a;
--accent: #fafafa;
--danger: #ef4444;
--success: #10b981;
--warning: #f59e0b;
--radius: 6px;
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'JetBrains Mono', 'Menlo', monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 24px;
background: var(--bg-body);
color: var(--text-primary);
font-family: var(--font-ui);
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.title {
font-size: 1.1rem;
font-weight: 600;
}
.btn {
border: 1px solid var(--bg-border);
background: transparent;
color: var(--text-primary);
padding: 8px 10px;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.85rem;
}
.btn:hover { background: var(--bg-hover); }
.btn-primary {
background: var(--accent);
color: #09090b;
border-color: var(--accent);
}
.btn-primary:hover { filter: brightness(0.92); background: var(--accent); }
.btn-danger {
background: var(--danger);
border-color: var(--danger);
color: white;
}
.btn-danger:hover { filter: brightness(0.92); background: var(--danger); }
.grid {
display: grid;
grid-template-columns: 1fr 380px;
gap: 16px;
}
.card-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid var(--bg-border);
background: #0f0f12;
color: var(--text-muted);
font-size: 0.78rem;
font-family: var(--font-mono);
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
padding: 14px;
overflow: hidden;
}
.panel h3 {
margin: 0 0 10px 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
label {
display: block;
margin: 10px 0 6px 0;
font-size: 0.85rem;
color: var(--text-muted);
}
textarea, select, input[type="text"] {
width: 100%;
padding: 10px;
border-radius: var(--radius);
border: 1px solid var(--bg-border);
background: #0f0f12;
color: var(--text-primary);
font-family: var(--font-ui);
font-size: 0.9rem;
outline: none;
}
textarea { min-height: 140px; resize: vertical; }
.row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
margin-top: 10px;
}
.row .btn { flex: 0 0 auto; }
.hint {
margin-top: 10px;
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.4;
}
.status {
margin-top: 10px;
padding: 10px;
border-radius: var(--radius);
border: 1px solid var(--bg-border);
background: #0f0f12;
font-family: var(--font-mono);
font-size: 0.8rem;
white-space: pre-wrap;
}
.stations {
max-height: 420px;
overflow: auto;
border: 1px solid var(--bg-border);
border-radius: var(--radius);
background: #0f0f12;
}
.station-toolbar {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
margin-top: 10px;
}
.station-item {
display: flex;
gap: 10px;
padding: 8px 10px;
border-bottom: 1px solid var(--bg-border);
align-items: center;
}
.station-item:last-child { border-bottom: none; }
.station-name { flex: 1; }
.station-meta {
font-family: var(--font-mono);
color: var(--text-muted);
font-size: 0.75rem;
}
audio { width: 100%; margin-top: 8px; }
@media (max-width: 980px) {
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="topbar">
<div class="title">临时广播</div>
<div class="row" style="margin-top:0">
<a class="btn" href="/index.html" style="text-decoration:none; display:inline-flex; align-items:center;">返回控制台</a>
</div>
</div>
<div class="grid">
<div class="panel">
<div class="card-title">
<h3 style="margin:0;">内容</h3>
<span class="badge" id="build_badge">未合成</span>
</div>
<label for="tb_text">广播文本(中文)</label>
<textarea id="tb_text" placeholder="请输入临时广播内容…"></textarea>
<label for="tb_urgency">紧急程度</label>
<select id="tb_urgency">
<option value="normal">正常(列车广播优先、可被打断后重播)</option>
<option value="emergency">紧急(最高优先级,拦停所有广播)</option>
</select>
<div class="row">
<button class="btn btn-primary" id="btn_compose" onclick="composeTempBroadcast()">合成临时广播(lsts.wav + 内容)</button>
<button class="btn btn-danger" id="btn_publish" onclick="publishTempBroadcast()" disabled>发布到所选站台</button>
</div>
<div class="hint" id="hint_area">
需要服务端已存在 lsts.wav(建议放在 uploads/audio/general/lsts.wav)。点击“合成”会自动中文 TTS 并拼接生成临时广播,随后可发布到所选站台。
</div>
<div id="preview_area" style="margin-top:12px; display:none;">
<label>合成结果预览</label>
<audio id="audio_broadcast" controls></audio>
</div>
<div class="status" id="status_box">就绪</div>
</div>
<div class="panel">
<div class="card-title">
<h3 style="margin:0;">目标站台</h3>
<span class="badge" id="sel_badge">已选 0</span>
</div>
<div class="station-toolbar">
<input type="text" id="station_filter" placeholder="搜索站台…" oninput="applyStationFilter()" />
<button class="btn" onclick="reloadStations()">刷新</button>
</div>
<div class="row">
<button class="btn" onclick="selectAll(true)">全选</button>
<button class="btn" onclick="selectAll(false)">全不选</button>
</div>
<div class="stations" id="stations_box" style="margin-top:10px;"></div>
</div>
</div>
<script>
const ZH_MODEL = "7b64d954530d490a8c40827e618e55c6";
let state = {
broadcastId: null,
broadcastUrl: null
};
function setStatus(msg) {
const box = document.getElementById("status_box");
box.textContent = String(msg || "");
}
function getSelectedStations() {
const nodes = document.querySelectorAll('input[data-station-name="1"]:checked');
const names = [];
for (const n of nodes) names.push(n.getAttribute("data-name"));
return names;
}
function updateSelectedBadge() {
const n = getSelectedStations().length;
document.getElementById("sel_badge").textContent = `已选 ${n}`;
}
async function reloadStations() {
const box = document.getElementById("stations_box");
box.innerHTML = "";
setStatus("加载站台列表中…");
try {
const resp = await fetch("/api/stations");
const data = await resp.json();
if (!data || !data.success) throw new Error(data?.message || "加载失败");
const stations = Array.isArray(data.stations) ? data.stations : [];
if (stations.length === 0) {
box.innerHTML = '<div class="station-item" style="color:var(--text-muted); font-style:italic;">暂无在线/已注册站台</div>';
setStatus("站台列表为空");
return;
}
for (const st of stations) {
const name = st.name || "";
const code = st.code || "";
const lastSeen = st.lastSeen ? new Date(st.lastSeen).toLocaleString() : "";
const row = document.createElement("div");
row.className = "station-item";
row.innerHTML = `
<input type="checkbox" data-station-name="1" data-name="${escapeHtml(name)}" onchange="updateSelectedBadge()" />
<div class="station-name">${escapeHtml(name)}</div>
<div class="station-meta">${escapeHtml(code)}${lastSeen ? " · " + escapeHtml(lastSeen) : ""}</div>
`;
box.appendChild(row);
}
setStatus("站台列表已加载");
updateSelectedBadge();
applyStationFilter();
} catch (e) {
setStatus("加载站台失败: " + (e?.message || String(e)));
}
}
function selectAll(on) {
const nodes = document.querySelectorAll('input[data-station-name="1"]');
for (const n of nodes) n.checked = !!on;
updateSelectedBadge();
}
function applyStationFilter() {
const keyword = String(document.getElementById("station_filter").value || "").trim().toLowerCase();
const rows = document.querySelectorAll("#stations_box .station-item");
for (const row of rows) {
const nameEl = row.querySelector(".station-name");
const metaEl = row.querySelector(".station-meta");
const text = `${nameEl ? nameEl.textContent : ""} ${metaEl ? metaEl.textContent : ""}`.toLowerCase();
row.style.display = keyword === "" || text.includes(keyword) ? "" : "none";
}
}
function escapeHtml(str) {
return String(str || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function composeTempBroadcast() {
const text = document.getElementById("tb_text").value.trim();
if (!text) {
setStatus("请输入广播文本");
return;
}
document.getElementById("btn_publish").disabled = true;
document.getElementById("btn_compose").disabled = true;
document.getElementById("build_badge").textContent = "合成中…";
setStatus("合成临时广播中…");
try {
const resp = await fetch("/api/temp-broadcast/compose", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, reference_id: ZH_MODEL, prefixName: "lsts" })
});
const data = await resp.json();
if (!data || !data.success) throw new Error(data?.message || "合成失败");
const preview = document.getElementById("preview_area");
preview.style.display = "block";
state.broadcastId = data.broadcastId;
state.broadcastUrl = data.wavUrl;
document.getElementById("build_badge").textContent = "已合成";
document.getElementById("audio_broadcast").src = state.broadcastUrl;
document.getElementById("btn_publish").disabled = false;
setStatus(`合成完成: broadcastId=${state.broadcastId} duration=${Number(data.durationSec || 0).toFixed(2)}s`);
} catch (e) {
setStatus("合成失败: " + (e?.message || String(e)));
document.getElementById("build_badge").textContent = "未合成";
} finally {
document.getElementById("btn_compose").disabled = false;
}
}
async function publishTempBroadcast() {
const stations = getSelectedStations();
if (!state.broadcastId) {
setStatus("请先合成临时广播");
return;
}
if (stations.length === 0) {
setStatus("请至少勾选一个站台");
return;
}
const urgency = document.getElementById("tb_urgency").value;
document.getElementById("btn_publish").disabled = true;
setStatus("发布中…");
try {
const resp = await fetch("/api/temp-broadcast/publish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ broadcastId: state.broadcastId, stations, urgency })
});
const data = await resp.json();
if (!data || !data.success) throw new Error(data?.message || "发布失败");
setStatus(`已发布: 成功=${data.sent || 0} 失败=${data.failed || 0}`);
} catch (e) {
setStatus("发布失败: " + (e?.message || String(e)));
} finally {
document.getElementById("btn_publish").disabled = false;
}
}
reloadStations();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+3620
View File
File diff suppressed because it is too large Load Diff
+255
View File
@@ -0,0 +1,255 @@
local aukit = require "aukit"
local speakers = {peripheral.find("speaker")}
if #speakers == 0 then error("No speaker attached") end
if #speakers == 2 and peripheral.getName(speakers[1]) == "right" and peripheral.getName(speakers[2]) == "left" then speakers = {speakers[2], speakers[1]} end
local path = ...
if not path then error("Usage: austream <file/URL> [arguments for PCM/DFPWM]") end
local params = {...}
table.remove(params, 1) -- Remove path
local v = {}
if #params > 0 then
-- Try to parse as Lua table string first (original behavior)
local paramStr = table.concat(params, " ")
-- Remove outer braces if they exist to match original austream format
paramStr = paramStr:gsub("^%s*{%s*", ""):gsub("%s*}%s*$", "")
local fn = load("return {" .. paramStr .. "}", "=unserialize", "t", setmetatable({}, {__index = function(_, idx) return idx end}))
if fn then
local ok, parsed = pcall(fn)
if ok and type(parsed) == "table" then
for k, val in pairs(parsed) do v[k] = val end
end
end
-- If that fails, try manual string matching
if not next(v) then
local matchStr = table.concat(params, ", ")
if matchStr:match("type%s*=%s*['\"]([^'\"]+)['\"]") then
v.type = matchStr:match("type%s*=%s*['\"]([^'\"]+)['\"]")
end
if matchStr:match("volume%s*=%s*([%d%.]+)") then v.volume = tonumber(matchStr:match("volume%s*=%s*([%d%.]+)")) end
if matchStr:match("streamData%s*=%s*true") then v.streamData = true end
if matchStr:match("silent%s*=%s*true") then v.silent = true end
end
end
if v.volume == nil then v.volume = 3 end
if type(v.volume) == "number" then
if v.volume < 0 then v.volume = 0 end
if v.volume > 3 then v.volume = 3 end
end
local mono = v.mono or #speakers == 1
aukit.defaultInterpolation = v.interpolation or aukit.defaultInterpolation
local data
if path:match("^https?://") then
local handle, err = http.get(path, nil, true)
if not handle then error("Could not connect to " .. path .. ": " .. err) end
local code = handle.getResponseCode()
if code ~= 200 then handle.close() error("Could not connect to " .. path .. ": HTTP " .. code) end
if v.streamData then
local closed = false
function data()
if closed then return nil end
local d = handle.read(48000)
if not d then handle.close() closed = true return nil end
return d
end
else
data = handle.readAll()
handle.close()
end
elseif path:match("^wss?://") then
local ws, err = http.websocket(path)
if not ws then error("Failed to connect to websocket: " .. tostring(err)) end
-- Buffer completely locally to avoid any network stutter
local fullBuffer = ""
local isDone = false
local function cleanup()
if not isDone then
isDone = true
if ws then ws.close() end
end
end
-- Initial wait for the first data chunk from the server (which sends a 1s silence chunk on connect)
local startWait = os.startTimer(1.0)
while true do
local event, p1, p2 = os.pullEvent()
if event == "websocket_message" and p1 == path then
if type(p2) == "string" then fullBuffer = fullBuffer .. p2 end
-- Once we get the first message, we can break and start playing
break
elseif event == "websocket_closed" and p1 == path then
cleanup()
break
elseif event == "timer" and p1 == startWait then
break
end
end
-- Since WebSocket connection needs to run alongside aukit stream reading,
-- we will just use a non-blocking read approach in the data function itself.
function data(n)
n = n or 6000
if isDone then
if #fullBuffer == 0 then return nil end
else
-- First, read any available messages without blocking
-- ws.receive(0) returns immediately. If nil, no message is ready.
while true do
local msg = ws.receive(0)
if msg == nil then break end
if type(msg) == "string" then
fullBuffer = fullBuffer .. msg
end
end
-- If we still don't have enough data, wait for more
-- but only if we know more might be coming (not isDone)
if #fullBuffer < n then
-- We only wait briefly so we don't block the audio thread too long
-- If the server isn't sending data, it means silence or end of stream
local timeout = os.startTimer(0.1) -- shorter timeout to keep pulling events
while #fullBuffer < n do
local event, p1, p2 = os.pullEvent()
if event == "websocket_message" and p1 == path then
if type(p2) == "string" then fullBuffer = fullBuffer .. p2 end
elseif event == "websocket_closed" and p1 == path then
cleanup()
break
elseif event == "timer" and p1 == timeout then
-- We hit timeout, we must return whatever we have to keep playing
break
end
end
end
end
if #fullBuffer == 0 and isDone then return nil end
-- Return DFPWM silence (0xAA) if buffer is empty but stream is alive
-- This keeps the stream alive without crashing or making noise
if #fullBuffer == 0 then return string.rep("\xAA", n) end
local ret
if #fullBuffer >= n then
ret = fullBuffer:sub(1, n)
fullBuffer = fullBuffer:sub(n + 1)
else
ret = fullBuffer
-- Pad with silence if we don't have enough data to fulfill the request
ret = ret .. string.rep("\xAA", n - #ret)
fullBuffer = ""
end
return ret
end
-- Note: aukit.stream handles the reading internally
-- We can use a table with a read function for custom streaming
local obj = {
read = function(n)
local d = data(n)
if not d and isDone then cleanup() end
return d
end,
close = cleanup
}
return obj
elseif path:match("^rednet://%d+") or path:match("^rednet%+%l+://%d+") then
peripheral.find("modem", rednet.open)
local proto, id, name = path:match("^rednet%+?(%l*)://(%d+)(/?.*)$")
id = tonumber(id)
if proto == "" then proto = nil end
if path ~= "" and id >= 0 then rednet.send(id, name, proto) end
function data()
local i, msg
repeat i, msg = rednet.receive(proto) until id == -1 or i == id
return msg
end
else
path = shell.resolve(...)
local file, err = fs.open(path, "rb")
if not file then error("Could not open " .. path .. ": " .. err) end
if v.streamData then
local closed = false
function data()
if closed then return nil end
local d = file.read(48000)
if not d then file.close() closed = true return nil end
return d
end
else
data = file.readAll()
file.close()
end
end
local iter, length
if type(v.type) == "string" then v.type = v.type:lower() end
local isDfpwm = v.type == "dfpwm" or path:lower():match("%.dfpwm$") or path:match("^wss?://")
if isDfpwm then
if type(data) == "table" and data.read then
iter, length = aukit.stream.dfpwm(data, v.sampleRate, v.channels, mono)
else
iter, length = aukit.stream.dfpwm(data, v.sampleRate, v.channels, mono)
end
elseif v.type == "mdfpwm" or path:lower():match("%.mdfpwm$") then iter, length = aukit.stream.mdfpwm(data, mono)
elseif v.type == "wav" or path:lower():match("%.wav$") then iter, length = aukit.stream.wav(data, mono, v.ignoreHeader)
elseif v.type == "aiff" or path:lower():match("%.aiff?$") then iter, length = aukit.stream.aiff(data, mono, v.ignoreHeader)
elseif v.type == "au" or path:lower():match("%.au$") then iter, length = aukit.stream.au(data, mono, v.ignoreHeader)
elseif v.type == "flac" or path:lower():match("%.flac$") then iter, length = aukit.stream.flac(data, mono)
elseif v.type == "qoa" or path:lower():match("%.qoa$") then iter, length = aukit.stream.qoa(data, mono)
elseif v.type == "pcm" or path:lower():match("%.pcm$") or path:lower():match("%.raw$") or path:match("^rednet%+?%l*://") then iter, length = aukit.stream.pcm(data, v.bitDepth, v.dataType, v.channels, v.sampleRate, v.bigEndian, mono)
else error("Unknown file type. Make sure to add the right file extension to the path/URL.") end
if length == nil then length = 0 end
if not v.silent then
print("Streaming...")
local w = term.getSize()
local y = select(2, term.getCursorPos())
local fg, bg = colors.toBlit(term.getTextColor()), colors.toBlit(term.getBackgroundColor())
term.write(("00:00 %s %02d:%02d"):format(("\127"):rep(w - 12), math.floor(length / 60), length % 60))
aukit.play(iter, function(pos)
pos = math.min(pos, 5999)
local p = pos / length
term.setCursorPos(1, y)
-- 计算基本时间字符串
local timeStr = ("%02d:%02d"):format(math.floor(pos / 60), pos % 60)
local endTimeStr = ("%02d:%02d"):format(math.floor(length / 60), length % 60)
if p > 1 then
-- 确保空格数量正确
local spaces = (" "):rep(w - 12)
local text = timeStr .. " " .. spaces .. "--:--"
local fgColor = fg:rep(#text)
local bgColor = bg:rep(6) .. fg:rep(w - 12) .. bg:rep(6)
-- 确保所有参数长度相等
if #text == #fgColor and #text == #bgColor then
term.blit(text, fgColor, bgColor)
end
else
local barWidth = w - 12
local progress = math.floor(barWidth * p)
progress = math.max(0, math.min(progress, barWidth)) -- 确保进度在有效范围内
local progressBar = (" "):rep(progress) .. ("\127"):rep(barWidth - progress)
local text = timeStr .. " " .. progressBar .. " " .. endTimeStr
local fgColor = fg:rep(#text)
local bgColor = bg:rep(6) .. fg:rep(progress) .. bg:rep(barWidth - progress + 6)
-- 确保所有参数长度相等
if #text == #fgColor and #text == #bgColor then
term.blit(text, fgColor, bgColor)
end
end
end, v.volume, table.unpack(speakers))
print()
else
aukit.play(iter, function() end, v.volume, table.unpack(speakers))
end
+83
View File
@@ -0,0 +1,83 @@
local STATION_BROADCAST_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/station_broadcast.lua?sign=xXsMwezjnEs4c6QrjkJkbvz1rARokmm87zwDcDDS5dk=:0"
local AUKIT_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/aukit.lua?sign=02_QDktYbSrHrtCDsFlKMkbEOWJGDautZaOtWkvg5q8=:0"
local AUSTREAM_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/austream.lua?sign=DrLbf9CIX1ZY8DaL8vliZiCMQzGos10nhG26Wf5lbgA=:0"
local function 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
local function writeFile(path, content)
local dir = fs.getDir(path)
if dir and dir ~= "" and not fs.exists(dir) then
fs.makeDir(dir)
end
local f = fs.open(path, "w")
if not f then
return nil, "cannot open " .. path
end
f.write(content)
f.close()
return true
end
local function downloadTo(url, path)
write(("Downloading %s... "):format(path))
local body, err = fetch(url)
if not body then
print("FAILED")
print(err)
return false
end
local ok, werr = writeFile(path, body)
if not ok then
print("FAILED")
print(werr)
return false
end
print("OK")
return true
end
local targets = {
{ url = STATION_BROADCAST_URL, path = shell.resolve("/startup") },
{ url = AUKIT_URL, path = shell.resolve("/aukit.lua") },
{ url = AUSTREAM_URL, path = shell.resolve("/austream.lua") }
}
for _, t in ipairs(targets) do
local ok = downloadTo(t.url, t.path)
if not ok then
print("Install aborted.")
return
end
end
print("Install complete.")
print("Run 'reboot' to start StationBroadcast on boot.")
+52
View File
@@ -0,0 +1,52 @@
-- Random Audio Player
-- Simplified version that only plays random audio files
local COLORS = {
INFO = colors.white,
WARN = colors.yellow,
WARNING = colors.yellow,
ERROR = colors.red,
DEBUG = colors.cyan,
SUCCESS = colors.green
}
-- Debug output function
local function debugPrint(level, message)
local timestamp = os.date("%H:%M:%S")
local logMessage = string.format("[%s][%s] %s", timestamp, level, message)
local color = COLORS[level] or colors.white
local oldColor = term.getTextColor()
term.setTextColor(color)
print(logMessage)
term.setTextColor(oldColor)
end
-- Add random audio playback function
local function playRandomAudio()
local audioFiles = {"aqtx", "jzxy"}
local randomFile = audioFiles[math.random(#audioFiles)]
local path = "/Audio/"..randomFile..".dfpwm"
if fs.exists(path) then
debugPrint("INFO", "Attempting to play: "..path)
shell.run("austream "..path.." volume=3.0")
debugPrint("INFO", "Finished playing: "..randomFile)
else
debugPrint("ERROR", "Audio file not found: "..path)
end
end
-- Main loop
local elapsedSeconds = 0
while true do
if elapsedSeconds >= 240 then
playRandomAudio()
elapsedSeconds = 0
end
os.sleep(1)
elapsedSeconds = elapsedSeconds + 1
end
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
local STATION_BROADCAST_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/station_broadcast.lua?sign=xXsMwezjnEs4c6QrjkJkbvz1rARokmm87zwDcDDS5dk=:0"
local AUKIT_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/aukit.lua?sign=02_QDktYbSrHrtCDsFlKMkbEOWJGDautZaOtWkvg5q8=:0"
local AUSTREAM_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/austream.lua?sign=DrLbf9CIX1ZY8DaL8vliZiCMQzGos10nhG26Wf5lbgA=:0"
local function 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
local function writeFile(path, content)
local dir = fs.getDir(path)
if dir and dir ~= "" and not fs.exists(dir) then
fs.makeDir(dir)
end
local f = fs.open(path, "w")
if not f then
return nil, "cannot open " .. path
end
f.write(content)
f.close()
return true
end
local function downloadTo(url, path)
write(("Updating %s... "):format(path))
local body, err = fetch(url)
if not body then
print("FAILED")
print(err)
return false
end
local ok, werr = writeFile(path, body)
if not ok then
print("FAILED")
print(werr)
return false
end
print("OK")
return true
end
local targets = {
{ url = STATION_BROADCAST_URL, path = shell.resolve("/startup") },
{ url = AUKIT_URL, path = shell.resolve("/aukit.lua") },
{ url = AUSTREAM_URL, path = shell.resolve("/austream.lua") }
}
for _, t in ipairs(targets) do
local ok = downloadTo(t.url, t.path)
if not ok then
print("Update aborted.")
return
end
end
print("Update complete.")