一、PC 端图表文字被拉伸(bug)
.spark 折线图把 viewBox 写死成 "0 0 300 48" 又带 preserveAspectRatio="none":
移动端元素宽≈300px 时缩放≈1 看不出问题,PC 上卡片宽约 1030px,横轴被放大 3.4 倍
而纵轴仍是 48px,图里的 <text> 被整体拉扁。现在按元素实际宽度算 viewBox 宽度
(取不到时回落 300),并监听 resize 重画;preserveAspectRatio 保留不影响 1:1 显示。
二、新增「转码」分区(transcode.go,第七个 tab)
原来只有「录播 + 弹幕 → 成片」一条路,已有成片想压小、换 HEVC、降分辨率只能手搓命令行。
现在从文件列表多选加入,每个文件一行独立配置:编码器、码率控制(跟随源片 / 目标码率 /
质量 CRF·ICQ / 源片百分比)、编码速度、输出帧率、分辨率(只列不高于源片的档)、
音频(拷贝 / 128k / 192k / 去掉),并实时预估产物大小与整批合计。
- POST /api/transcode/probe | estimate | transcode,都在既有的口令鉴权之后
- 目标码率模式按「码率 × 时长」出准数;质量模式用单调系数表插值并标注是粗估
- 落盘沿用合并那套:先在源文件所在目录生成,完成后再 placeOutput 到输出目录,
失败或中断删临时文件,不留半成品
- 与合并共用同一张进度卡片和任务位(mergeSlot),串行排队,可暂停 / 停止
三、手动合并改成「一次排多个任务」
原来一次只能合一场。现在多选视频后按「文件名里的录制时间 + 主体名」自动归组
(相邻两段间隔 2 小时内算同一场),每组一个任务卡,各自配弹幕、偏移、输出名和
合成参数(码率 / 帧率 / 字号 / 编码器),可拆组、可移除;服务端串行跑,逐个回传结果。
请求体从 {videos,danmu,...} 扩成 {jobs:[{...}]},老的单任务写法继续可用。
四、审阅中一并修掉 / 加固的点
- 容器总码率含音频,直接当视频码率用会错两处:预估体积时又加一遍音轨(算成双份),
「跟随源片」时把音轨码率也编进视频里。现在分开记「视频流码率」与「容器总码率」,
转码侧按「视频流码率 → 容器码率 − 音轨 → 体积 ÷ 时长 − 音轨」三级取值;
合并的自动码率仍按老口径(容器含音频),单独用 streamOrContainerKbps 锁住
- 源片本来没有音轨时,预估不再凭空加 128 kbps 音频
- 目标码率加上界(200 Mbps),单位写错的数在开跑前就被挡住(合并的逐任务码率同理)
- 探测结果加缓存(ffmpeg + 路径 + 体积 + 修改时间),体积估算跟着输入框重发时不再
每次都起一个 ffmpeg;文件一变(录制中追加、换了文件)缓存自然失效
- 只有音轨 / 只有封面图的文件、以及正在写入的文件,排队时就被挡住并说清原因
- probeMedia / parseMediaInfo 合并了原先重复的两套解析(probeDuration 与
probeVideoStream 各起一次 ffmpeg 解析同一段输出),burnDanmu 现在一次探测拿全
- 前端:估算参数非法时把服务端给的原因显示在合计行(原来静默不动,像是没改成功);
估算请求加序号,慢的旧回包不再覆盖新结果;转码下拉刷新时保留已勾选项;
跑完的行标「完成 / 失败」并淡化;删掉没有对应元素、从未使用的 CSS 规则
验证
- gofmt / go vet 干净;go build(windows)与 GOOS=linux go build 均通过
- go test ./... 20.9s 全过;go test -race ./... 26.1s 全过(182 → 206 个用例,
transcode_test.go 新增 20 个)
- 反向验证 C:\OtherFiles\acf-test\reverse_check.py 从 37 项加到 43 项,全过
- 51899 隔离实例 + 真 ffmpeg 冒烟 29 项全过:401 鉴权、probe 的 hasVideo/hasAudio、
ts 容器(不带每流码率)探测到的视频码率 = 容器 429 − 音轨 127、
HEVC 按 0.65 折算、参数非法 400 带原因、只有音轨的文件排队即被拒、
输出目录不存在被拒、两任务队列真转成功并落到指定目录、缩放成片高度确实是 240、
日志里带「第 n/N 个」和参数摘要
- 页面 jsdom 冒烟 37 项全过、无 JS 报错(分组 / 拆组 / 逐任务参数 / 转码行渲染 /
跳过无视频轨文件 / 估算失败提示 / 结果徽标)
3236 lines
152 KiB
Go
3236 lines
152 KiB
Go
// web UI 相关
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"crypto/subtle"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// 储存日志
|
||
var logString struct {
|
||
sync.Mutex
|
||
str strings.Builder
|
||
total int64 // 从启动到现在写入的总字节数,用于按偏移量增量读取日志
|
||
}
|
||
|
||
const webuiHTML = `<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="color-scheme" content="light dark">
|
||
<title>AcFun 直播录制</title>
|
||
<style>
|
||
:root{
|
||
--bg:#f4f5f7;--card:#fff;--line:#e6e8ec;--soft:#eff1f4;--soft2:#f7f8fa;
|
||
--text:#1f2329;--sub:#8a9099;--accent:#fd4c5d;--accent-soft:#ffecee;
|
||
--ok:#1d7a46;--ok-soft:#e7f5ed;--bad:#d63046;
|
||
--topbar:rgba(255,255,255,.86);
|
||
--code-bg:#1c1f26;--code-fg:#c9ced8;
|
||
--radius:12px;--radius-s:8px;
|
||
--shadow:0 1px 2px rgba(16,20,28,.04),0 6px 16px -10px rgba(16,20,28,.18);
|
||
}
|
||
@media (prefers-color-scheme:dark){
|
||
:root{
|
||
--bg:#15171c;--card:#1e2127;--line:#2b2f37;--soft:#262a32;--soft2:#22262d;
|
||
--text:#e7e9ed;--sub:#959ca7;--accent:#fd4c5d;--accent-soft:#3a2126;
|
||
--ok:#59c98a;--ok-soft:#1e3328;--bad:#ff7183;
|
||
--topbar:rgba(30,33,39,.86);
|
||
--code-bg:#101216;--code-fg:#c2c7d0;
|
||
--shadow:0 1px 2px rgba(0,0,0,.3),0 8px 20px -12px rgba(0,0,0,.6);
|
||
}
|
||
}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 system-ui,-apple-system,"Microsoft YaHei",sans-serif;-webkit-text-size-adjust:100%}
|
||
a{color:var(--accent)}
|
||
/* 顶栏 */
|
||
.top{position:sticky;top:0;z-index:5;background:var(--topbar);backdrop-filter:saturate(1.5) blur(12px);-webkit-backdrop-filter:saturate(1.5) blur(12px);border-bottom:1px solid var(--line)}
|
||
.top-in{max-width:1060px;margin:0 auto;padding:10px 14px 8px;display:flex;align-items:center;gap:12px}
|
||
.brand{display:flex;align-items:center;gap:10px;min-width:0;flex:1}
|
||
.logo{width:30px;height:30px;border-radius:9px;background:var(--accent);color:#fff;font-weight:700;font-size:15px;display:flex;align-items:center;justify-content:center;flex:none;box-shadow:0 2px 8px -2px rgba(253,76,93,.6)}
|
||
.bt{min-width:0}
|
||
.bt strong{display:block;font-size:14px;font-weight:600;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.head-sub{display:block;font-size:11.5px;color:var(--sub);line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.top-act{display:flex;align-items:center;gap:8px;flex:none}
|
||
.stat{font-size:12px;color:var(--sub);display:inline-flex;align-items:center;gap:5px;white-space:nowrap}
|
||
.dot{width:6px;height:6px;border-radius:50%;background:var(--sub);display:inline-block;flex:none}
|
||
.dot.rec{background:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
||
.dot.live{background:var(--ok);box-shadow:0 0 0 3px var(--ok-soft)}
|
||
.dot.warn{background:var(--bad);box-shadow:0 0 0 3px var(--accent-soft)}
|
||
/* 有没提交的修改时把保存按钮标出来,手机上光看输入框看不出来 */
|
||
#saveBtn{position:relative}
|
||
#saveBtn.dirty{box-shadow:0 0 0 3px var(--accent-soft)}
|
||
#saveBtn.dirty::after{content:"";position:absolute;top:-4px;right:-4px;width:9px;height:9px;border-radius:50%;background:var(--bad);border:2px solid var(--card)}
|
||
/* 分区导航 */
|
||
.tabs{max-width:1060px;margin:0 auto;padding:0 14px 10px;display:flex;gap:4px;overflow-x:auto;scrollbar-width:none}
|
||
.tabs::-webkit-scrollbar{display:none}
|
||
.tab{flex:0 0 auto;border:none;background:transparent;color:var(--sub);font:inherit;font-size:13px;padding:6px 14px;border-radius:999px;cursor:pointer;transition:background .15s,color .15s}
|
||
.tab:hover{background:var(--soft);color:var(--text)}
|
||
.tab.on{background:var(--card);color:var(--text);font-weight:600;box-shadow:var(--shadow)}
|
||
.tab .n{font-size:11px;opacity:.7;margin-left:4px;font-variant-numeric:tabular-nums}
|
||
/* 内容 */
|
||
.wrap{max-width:1060px;margin:0 auto;padding:14px 14px 40px}
|
||
.pane{display:none}
|
||
.pane.on{display:block;animation:fade .18s ease-out}
|
||
@keyframes fade{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
|
||
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px;margin-bottom:12px;box-shadow:var(--shadow)}
|
||
.card-h{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin-bottom:12px}
|
||
.card-h h2{font-size:14px;font-weight:600;margin:0}
|
||
.card-h .sub{font-size:12px;color:var(--sub)}
|
||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px 16px}
|
||
.f label{display:block;font-size:12px;color:var(--sub);margin-bottom:5px}
|
||
input[type=text],select,textarea{width:100%;border:1px solid var(--line);border-radius:var(--radius-s);padding:8px 10px;font:inherit;background:var(--card);color:var(--text);transition:border-color .15s,box-shadow .15s}
|
||
input[type=text]:hover,select:hover,textarea:hover{border-color:var(--sub)}
|
||
input[type=text]:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
||
textarea{resize:vertical;line-height:1.5;font-size:13px}
|
||
select[multiple]{padding:5px}
|
||
input[readonly]{background:var(--soft2);color:var(--sub)}
|
||
#mrgInfo{white-space:pre-wrap;margin-top:10px;font-size:12.5px;line-height:1.7;color:var(--text);background:var(--soft2);border-radius:var(--radius-s);padding:10px 12px}
|
||
#mrgInfo:empty{display:none}
|
||
select.brsel{padding:6px;max-width:200px}
|
||
input.brnum{width:76px;text-align:center;padding:6px 8px}
|
||
button.mini{padding:4px 6px;font-size:11.5px}
|
||
/* 开关 */
|
||
.sws{display:flex;flex-wrap:wrap;gap:10px 22px}
|
||
.sw{display:inline-flex;align-items:center;gap:9px;cursor:pointer;font-size:13px;position:relative}
|
||
.sw input{position:absolute;width:1px;height:1px;opacity:0;margin:0;pointer-events:none}
|
||
.sw-i{width:36px;height:21px;border-radius:999px;background:#d3d7de;flex:none;position:relative;transition:background .16s}
|
||
.sw-i::after{content:"";position:absolute;top:2px;left:2px;width:17px;height:17px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.24);transition:transform .16s}
|
||
.sw input:checked+.sw-i{background:var(--accent)}
|
||
.sw input:checked+.sw-i::after{transform:translateX(15px)}
|
||
.sw input:focus-visible+.sw-i{box-shadow:0 0 0 3px var(--accent-soft)}
|
||
/* 小圆片勾选(主播列表) */
|
||
.pill{display:inline-flex;align-items:center;gap:6px;padding:4px 11px;border-radius:999px;background:var(--soft);font-size:12px;color:var(--sub);cursor:pointer;user-select:none;border:1px solid transparent;transition:.15s}
|
||
.pill input{appearance:none;-webkit-appearance:none;width:14px;height:14px;margin:0;border:1.5px solid #c3c9d2;border-radius:4px;background:var(--card);position:relative;flex:none;cursor:pointer}
|
||
.pill input:checked{background:var(--accent);border-color:var(--accent)}
|
||
.pill input:checked::after{content:"";position:absolute;left:3px;top:.5px;width:4px;height:8px;border:2px solid #fff;border-top:0;border-left:0;transform:rotate(45deg)}
|
||
.pill:has(input:checked){background:var(--accent-soft);color:var(--accent);border-color:rgba(253,76,93,.25)}
|
||
/* 按钮 */
|
||
button{border:none;border-radius:var(--radius-s);padding:8px 16px;font:inherit;font-size:13px;cursor:pointer;background:var(--accent);color:#fff;transition:filter .15s,background .15s;white-space:nowrap}
|
||
button:hover{filter:brightness(1.06)}
|
||
button:active{filter:brightness(.94)}
|
||
button:disabled{opacity:.5;cursor:default;filter:none}
|
||
button.ghost,a.ghost{background:var(--soft);color:var(--text);padding:7px 12px;font-size:12.5px;text-decoration:none;display:inline-block}
|
||
button.ghost:hover,a.ghost:hover{background:var(--soft2);filter:none}
|
||
.hint{font-size:12px;color:var(--sub);margin-top:8px;line-height:1.65}
|
||
.hint.bad{color:var(--bad)}
|
||
.bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:12px}
|
||
.bar .hint{margin:0}
|
||
.rowline{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||
.rowline input{max-width:240px}
|
||
/* 主播列表 / 文件列表 */
|
||
.row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;padding:10px 10px;border-radius:var(--radius-s);transition:background .12s}
|
||
.row+.row{border-top:1px solid var(--line)}
|
||
.row:hover{background:var(--soft2)}
|
||
.uid{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}
|
||
.name{font-weight:600;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.title{flex:1 1 200px;min-width:0;font-size:12px;color:var(--sub);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.ops{margin-left:auto;display:flex;gap:6px;flex-wrap:wrap}
|
||
/* 文件名是拿来认文件的关键信息,在文件列表里放开宽度不要截 */
|
||
#files .name{max-width:none;flex:1 1 240px;word-break:break-all;white-space:normal}
|
||
.badge{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11.5px;margin-right:4px;background:var(--soft);color:var(--sub)}
|
||
.badge.on{background:var(--accent-soft);color:var(--accent)}
|
||
.badge.rec{background:var(--ok-soft);color:var(--ok)}
|
||
.off{font-size:12.5px;color:var(--sub);padding:8px 0}
|
||
pre{background:var(--code-bg);color:var(--code-fg);border-radius:var(--radius-s);padding:12px;font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,"Courier New",monospace;max-height:340px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}
|
||
/* 提示条 */
|
||
#toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:20;display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none;width:calc(100% - 24px);max-width:460px}
|
||
.toast{background:var(--card);color:var(--text);border:1px solid var(--line);border-left:3px solid var(--ok);border-radius:var(--radius-s);padding:10px 14px;font-size:12.5px;line-height:1.6;box-shadow:0 8px 28px -10px rgba(16,20,28,.35);animation:tin .18s ease-out;max-width:100%;word-break:break-word}
|
||
.toast.bad{border-left-color:var(--bad)}
|
||
.toast.out{opacity:0;transform:translateY(6px);transition:.2s}
|
||
@keyframes tin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
||
/* 口令弹窗 */
|
||
.mask{position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(16,18,22,.5);backdrop-filter:blur(3px);display:none;align-items:center;justify-content:center;z-index:30;padding:16px}
|
||
.mask.on{display:flex}
|
||
.mask .box{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px;width:100%;max-width:390px;box-shadow:0 20px 50px -20px rgba(0,0,0,.5)}
|
||
.mask h3{margin:0 0 6px;font-size:15px}
|
||
.mask p{margin:0 0 12px;font-size:12px;color:var(--sub);line-height:1.65}
|
||
#authMsg{font-size:12px;color:var(--bad);min-height:17px;margin-top:8px}
|
||
/* 路径输入框:手工填和点「浏览」并存 */
|
||
.pathf{display:flex;gap:8px;align-items:center}
|
||
.pathf input{flex:1 1 auto;min-width:0;max-width:none}
|
||
.pathf button{flex:none}
|
||
/* 路径选择弹窗 */
|
||
#pick .box{max-width:620px}
|
||
#pickPath{font-size:12.5px}
|
||
.roots{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
|
||
.roots:empty{display:none}
|
||
.roots button{background:var(--soft);color:var(--text);font-size:12px;padding:4px 10px;border-radius:999px}
|
||
.roots button:hover{background:var(--soft2)}
|
||
.plist{max-height:44vh;overflow:auto;border:1px solid var(--line);border-radius:var(--radius-s);background:var(--soft2)}
|
||
.plist .off{padding:10px}
|
||
.pi{display:flex;align-items:center;gap:10px;padding:7px 10px;font-size:13px;cursor:pointer}
|
||
.pi+.pi{border-top:1px solid var(--line)}
|
||
.pi:hover{background:var(--card)}
|
||
.pi.on{background:var(--accent-soft);color:var(--accent)}
|
||
.pi-n{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.pi-dir .pi-n{font-weight:600}
|
||
.pi-m{flex:none;font-size:11.5px;color:var(--sub);font-variant-numeric:tabular-nums}
|
||
/* 多选模式(选视频这种可以选多个的框):文件条目左边给个勾选框 */
|
||
.pi-c{display:none;flex:none;width:15px;height:15px;border:1px solid var(--line);border-radius:4px;background:var(--card)}
|
||
#pick.multi .pi:not(.pi-dir) .pi-c{display:block}
|
||
#pick.multi .pi.on .pi-c{border-color:var(--accent);background:var(--accent)}
|
||
#pick.multi .pi.on .pi-c::after{content:"";display:block;width:4px;height:8px;margin:1px 0 0 4px;border:solid #fff;border-width:0 2px 2px 0;transform:rotate(45deg)}
|
||
.lnk{color:var(--accent);cursor:pointer;text-decoration:underline}
|
||
.only-m{display:none}
|
||
@media (max-width:700px){
|
||
/* 手机上标题居中、两侧各留一列等宽(左右各只有一个按钮,视觉上才是正的居中) */
|
||
.top-in{padding:9px 12px 7px;gap:8px;display:grid;grid-template-columns:1fr auto 1fr}
|
||
.brand{grid-column:2;justify-content:center}
|
||
.top-act{grid-column:3;justify-content:flex-end}
|
||
.head-sub{display:none}
|
||
.stat{display:none}
|
||
.only-d{display:none}
|
||
.only-m{display:inline}
|
||
.wrap{padding:12px 12px 36px}
|
||
.card{padding:14px;border-radius:10px}
|
||
.grid{grid-template-columns:1fr}
|
||
.row{gap:6px 10px}
|
||
.name{max-width:none}
|
||
.title{flex:1 1 100%;order:8}
|
||
.ops{width:100%;margin-left:0;order:9}
|
||
.ops button,.ops a{flex:1 1 0;padding:8px 4px;text-align:center}
|
||
button.ghost,.mask button{padding:8px 12px;font-size:13px}
|
||
#toast{bottom:14px}
|
||
}
|
||
/* 转换任务进度:监控分区里的卡片 + 页面顶部的全局细进度条 */
|
||
#jobTop{position:fixed;left:0;right:0;top:0;height:3px;z-index:12;pointer-events:none;display:none}
|
||
#jobTop.on{display:block}
|
||
#jobTop i{display:block;height:100%;width:0;background:var(--accent);transition:width .6s linear}
|
||
.jtop{display:flex;align-items:baseline;gap:8px;font-size:12px;color:var(--sub);flex-wrap:wrap}
|
||
.jtop b{font-size:15px;color:var(--text);font-variant-numeric:tabular-nums}
|
||
.jbar{height:8px;border-radius:999px;background:var(--soft);overflow:hidden;margin:8px 0 2px}
|
||
.jbar i{display:block;height:100%;width:0;border-radius:999px;background:var(--accent);transition:width .6s linear}
|
||
.jbar.paused i{background:var(--sub)}
|
||
/* 阶段步骤条 */
|
||
.steps{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px}
|
||
.step{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;color:var(--sub);background:var(--soft2);border-radius:999px;padding:3px 10px}
|
||
.step .d{width:6px;height:6px;border-radius:50%;background:var(--line);flex:none}
|
||
.step.done .d{background:var(--ok)}
|
||
.step.on{color:var(--accent);background:var(--accent-soft);font-weight:600}
|
||
.step.on .d{background:var(--accent)}
|
||
.step.paused{color:var(--bad)}
|
||
.step.paused .d{background:var(--bad)}
|
||
/* 指标网格 */
|
||
.jgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(108px,1fr));gap:8px 14px;margin-top:10px}
|
||
.jcell{min-width:0}
|
||
.jcell .k{font-size:11px;color:var(--sub);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.jcell .v{font-size:13px;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
/* 输入 / 预计输出体积对比条,两段按较大值等比缩放 */
|
||
.vbar{display:flex;height:8px;border-radius:999px;overflow:hidden;background:var(--soft);margin-top:5px}
|
||
.vbar span{height:100%}
|
||
.vbar .vin{background:var(--sub)}
|
||
.vbar .vout{background:var(--accent)}
|
||
.vlg{display:flex;gap:14px;font-size:11.5px;color:var(--sub);margin-top:4px}
|
||
.vlg i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:5px}
|
||
/* 一个文件/一个任务一行:标题 + 参数网格 + 结果行 */
|
||
.trow{border:1px solid var(--line);border-radius:var(--radius-s);padding:10px 12px;margin-bottom:10px;background:var(--soft2)}
|
||
.trow .th{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}
|
||
.trow .tn{font-weight:600;font-size:13px;flex:1 1 200px;min-width:0;word-break:break-all}
|
||
.trow .tm{font-size:11.5px;color:var(--sub);font-variant-numeric:tabular-nums}
|
||
.trow .tvid{flex:1 1 100%;font-size:11.5px;color:var(--sub);word-break:break-all;line-height:1.6}
|
||
.trow .tg{display:grid;grid-template-columns:repeat(auto-fit,minmax(118px,1fr));gap:8px 10px;margin-top:9px}
|
||
.trow label{display:block;font-size:11px;color:var(--sub);margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.trow select,.trow input[type=text]{width:100%;padding:5px 7px;font-size:12.5px}
|
||
.trow input.tn-num{text-align:center}
|
||
.trow .tr{margin-top:9px;font-size:12px;color:var(--sub);display:flex;gap:12px;flex-wrap:wrap;align-items:baseline}
|
||
.trow .tr b{color:var(--text);font-size:13px;font-variant-numeric:tabular-nums}
|
||
.trow .tbadge{font-size:11.5px;padding:2px 8px;border-radius:999px;background:var(--soft);color:var(--sub)}
|
||
.trow .tbadge.ok{background:var(--ok-soft);color:var(--ok)}
|
||
/* 失败用红字(--bad),别用主色:主色在深色主题下和「进行中」的灰看着差不多 */
|
||
.trow .tbadge.bad{background:var(--accent-soft);color:var(--bad)}
|
||
/* 已经转完的那一行淡化一点,一眼看出还剩哪些没做 */
|
||
.trow.done{opacity:.72}
|
||
@media (max-width:700px){
|
||
.trow .tg{grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}
|
||
}
|
||
/* 速度折线:颜色走 CSS 变量,属性里写 var() 在部分浏览器不生效 */
|
||
.spark{display:block;width:100%;height:48px;margin-top:10px;border-radius:var(--radius-s);background:var(--soft2)}
|
||
.spark polyline{fill:none;stroke:var(--accent);stroke-width:1.6;stroke-linejoin:round;stroke-linecap:round;vector-effect:non-scaling-stroke}
|
||
.spark text{font-size:9px;fill:var(--sub)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="jobTop"><i id="jobTopBar"></i></div>
|
||
<header class="top">
|
||
<div class="top-in">
|
||
<div class="brand">
|
||
<span class="logo">A</span>
|
||
<div class="bt"><strong>AcFun 直播录制</strong><span class="head-sub">弹幕 / 视频录制,直播结束后自动合并</span></div>
|
||
</div>
|
||
<div class="top-act">
|
||
<span class="stat only-d" id="dirtyTip" style="display:none"><i class="dot warn"></i>有未保存的修改</span>
|
||
<span class="stat" id="topStat"></span>
|
||
<button id="saveBtn" onclick="saveSettings(this)"><span class="only-d">保存设置</span><span class="only-m">保存</span></button>
|
||
</div>
|
||
</div>
|
||
<nav class="tabs" id="tabs">
|
||
<button class="tab on" data-tab="mon">监控</button>
|
||
<button class="tab" data-tab="rec">录制</button>
|
||
<button class="tab" data-tab="mrg">合并</button>
|
||
<button class="tab" data-tab="trc">转码</button>
|
||
<button class="tab" data-tab="ntf">通知</button>
|
||
<button class="tab" data-tab="file">文件</button>
|
||
<button class="tab" data-tab="sys">设置</button>
|
||
</nav>
|
||
</header>
|
||
<div class="wrap">
|
||
|
||
<!-- 监控 -->
|
||
<section class="pane on" data-pane="mon">
|
||
<div class="card" id="jobCard" style="display:none">
|
||
<div class="card-h">
|
||
<h2 id="jobTitle">转换任务</h2>
|
||
<span class="sub" id="jobOut"></span>
|
||
<span style="flex:1 1 auto"></span>
|
||
<button class="ghost" id="jobPause" data-act="pause" onclick="jobControl(this.dataset.act)">暂停</button>
|
||
<button class="ghost" id="jobStop" onclick="jobControl('stop')">停止</button>
|
||
</div>
|
||
<div class="steps" id="jobSteps"></div>
|
||
<div class="jtop">
|
||
<b id="jobPct">0%</b>
|
||
<span id="jobPhase"></span>
|
||
<span style="flex:1 1 auto"></span>
|
||
<span id="jobEta"></span>
|
||
</div>
|
||
<div class="jbar" id="jobBarWrap"><i id="jobBar"></i></div>
|
||
<div class="jgrid" id="jobNums"></div>
|
||
<div id="jobVol" style="display:none">
|
||
<div class="vbar"><span class="vin" id="jobVolIn"></span><span class="vout" id="jobVolOut"></span></div>
|
||
<div class="vlg">
|
||
<span><i style="background:var(--sub)"></i>源片 <b id="jobVolInText">—</b></span>
|
||
<span><i style="background:var(--accent)"></i>预计成片 <b id="jobVolOutText">—</b></span>
|
||
<span id="jobVolNote"></span>
|
||
</div>
|
||
</div>
|
||
<div id="jobSparkWrap" style="display:none">
|
||
<svg class="spark" id="jobSpark" viewBox="0 0 300 48" preserveAspectRatio="none"></svg>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-h"><h2>添加主播</h2><span class="sub">填 uid 后默认同时开启弹幕和录播</span></div>
|
||
<div class="rowline">
|
||
<input type="text" id="newuid" inputmode="numeric" placeholder="主播 uid,例如 23682490">
|
||
<button class="ghost" onclick="addUid()">添加</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-h"><h2>主播列表</h2><span class="sub">勾选 = 开启对应录制,改完立即生效</span></div>
|
||
<div class="hint" style="margin:0 0 10px">码率这里选的是直播的<b>实际档位</b>(如蓝光 4M / 高清),开播后自动读取,也能点每行的「档位」按钮立刻读一次。默认「自动 · 第二高」= 按这次读到档位排序自上往下取第二高(不一定有蓝光 8M,所以只按实际档位排),也可以直接选最高、最低或中间某一档。正在录制时换档会自动重录。</div>
|
||
<div id="st"></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-h"><h2>运行日志</h2><span class="sub">手动合并的进度也在这里</span></div>
|
||
<pre id="log"></pre>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 录制 -->
|
||
<section class="pane" data-pane="rec">
|
||
<div class="card">
|
||
<div class="card-h"><h2>保存位置</h2><span class="sub">录播、弹幕和成片都放在这里</span></div>
|
||
<div class="grid">
|
||
<div class="f"><label>文件保存目录(留空 = 程序所在目录)</label>
|
||
<div class="pathf">
|
||
<input type="text" id="directory" placeholder="例如 D:\record">
|
||
<button class="ghost" onclick="openPick('directory','dir')">浏览…</button>
|
||
</div>
|
||
</div>
|
||
<div class="f"><label>录播 / 弹幕子文件夹</label><input type="text" id="recordSubdir" placeholder="record"></div>
|
||
<div class="f"><label>成片子文件夹(在保存目录下)</label><input type="text" id="mergeSubdir" placeholder="merged"></div>
|
||
</div>
|
||
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,开关和下拉框改完立即生效;没保存就离开页面或刷新会丢掉改动。</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-h"><h2>录制参数</h2></div>
|
||
<div class="grid">
|
||
<div class="f"><label>录播文件格式</label><select id="recordFormat">
|
||
<option value="mp4">mp4(推荐,最通用)</option>
|
||
<option value="ts">ts(强杀后还能播已录部分)</option>
|
||
<option value="flv">flv(兼容性一般)</option>
|
||
<option value="mkv">mkv(可装多音轨)</option>
|
||
</select></div>
|
||
<div class="f"><label>新主播的默认录制档位</label>
|
||
<select id="defaultQualityPref">
|
||
<option value="top">最高档</option>
|
||
<option value="second">第二高</option>
|
||
<option value="lowest">最低档</option>
|
||
<option value="custom">自定义码率上限</option>
|
||
</select></div>
|
||
<div class="f" id="defBrRow"><label>默认自定义码率上限(Kbps,取不超过它的最高档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 3000"></div>
|
||
<div class="f"><label>ffmpeg 路径(留空自动查找)</label>
|
||
<div class="pathf">
|
||
<input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe">
|
||
<button class="ghost" onclick="openPick('ffmpegPath','file')">浏览…</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="hint">录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录。</div>
|
||
<div class="hint">录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 合并 -->
|
||
<section class="pane" data-pane="mrg">
|
||
<div class="card">
|
||
<div class="card-h"><h2>自动合并</h2><span class="sub">直播正常结束时的自动处理</span></div>
|
||
<div class="sws">
|
||
<label class="sw"><input type="checkbox" id="autoMerge"><span class="sw-i"></span>直播结束后自动合并</label>
|
||
<label class="sw"><input type="checkbox" id="mergeDelVideo"><span class="sw-i"></span>合并后删除源视频</label>
|
||
<label class="sw"><input type="checkbox" id="mergeDelDanmu"><span class="sw-i"></span>合并后删除弹幕</label>
|
||
</div>
|
||
<div class="hint">三项互不影响,可以同时开启;需该主播同时开启弹幕和录播且都录到直播结束。手动停止或重新开始不会触发自动合并,那种情况用下面的手动合并。</div>
|
||
<div class="hint">同一主播下播后 20 秒内又上播会当成网络波动,接着录同一场,最终只合成一个成片。</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-h"><h2>成片输出</h2></div>
|
||
<div class="grid">
|
||
<div class="f"><label>成片移动到(留空 = 保存目录下的成片文件夹)</label>
|
||
<div class="pathf">
|
||
<input type="text" id="mergeOutputDir" placeholder="例如 D:\merged">
|
||
<button class="ghost" onclick="openPick('mergeOutputDir','dir')">浏览…</button>
|
||
</div>
|
||
</div>
|
||
<div class="f"><label>合成码率(Kbps,0 = 源片码率 + 500)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500,填 0 自动"></div>
|
||
<div class="f"><label>输出帧率(0 = 跟随源片)</label><input type="text" id="mergeFps" inputmode="numeric" placeholder="0 跟随源片;填例如 60 让低帧率录播里的弹幕更顺滑"></div>
|
||
<div class="f"><label>弹幕字号(像素,0 = 自动按画面分辨率)</label><input type="text" id="danmuFontSize" inputmode="numeric" placeholder="0 自动,或填例如 42;头像随字号一起缩放"></div>
|
||
</div>
|
||
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-h"><h2>手动合并</h2><span class="sub">自己挑视频和弹幕合成,可以一次排多个</span></div>
|
||
<div class="hint" style="margin:0 0 12px">自动合并只在录制自然结束时触发(手动停止过、弹幕和视频对不上号、或者把文件挪过位置的都不会自动合并),这些可以在这里自己挑文件合成。选多个视频会按文件名里的录制时间和标题自动归组:同一场断流分成的几段归到一个任务、先拼起来再合成,不同场次各排一个任务、依次合并出多个成片。每个任务的弹幕和参数都能单独改。</div>
|
||
<div class="grid">
|
||
<div class="f">
|
||
<label>从文件列表选视频(可多选,按 Ctrl 点选)</label>
|
||
<select id="mrgVideoPick" multiple size="5" onchange="pickVideos()"></select>
|
||
<div class="pathf" style="margin-top:8px">
|
||
<button class="ghost" onclick="openPick(null,'file',1,function(v){addMergeVideos(v)})">浏览…</button>
|
||
<span class="hint" style="margin:0;flex:1 1 auto;min-width:0">也能从别的文件夹挑,选完追加成新任务</span>
|
||
</div>
|
||
</div>
|
||
<div class="f">
|
||
<label>待合并的任务</label>
|
||
<div id="mrgJobs"></div>
|
||
<div class="off" id="mrgEmpty">还没有选视频</div>
|
||
<datalist id="mrgDanmuList"></datalist>
|
||
</div>
|
||
</div>
|
||
<div class="bar">
|
||
<button class="ghost" id="mrgCheck" onclick="checkMerge()">检查</button>
|
||
<button id="mrgGo" onclick="startMerge()">开始合并</button>
|
||
<span class="hint" id="mrgState"></span>
|
||
</div>
|
||
<div class="hint" id="mrgInfo"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 转码 -->
|
||
<section class="pane" data-pane="trc">
|
||
<div class="card">
|
||
<div class="card-h"><h2>选择要转码的文件</h2><span class="sub">从录播和成片文件夹里挑</span></div>
|
||
<div class="bar" style="margin-top:0">
|
||
<select id="tcFilePick" multiple size="6" style="flex:1 1 260px;min-width:0"></select>
|
||
<div style="flex:1 1 160px;display:flex;flex-direction:column;gap:8px">
|
||
<button class="ghost" onclick="addTranscodeFiles()">加入列表</button>
|
||
<button class="ghost" onclick="clearTranscodeFiles()">清空列表</button>
|
||
<span class="hint" style="margin:0">按住 Ctrl 点可以一次选多个</span>
|
||
</div>
|
||
</div>
|
||
<div class="hint">加入列表后会先读一遍每个文件的分辨率、帧率、码率和时长,参数按读出来的值给默认。转码不动原文件,成片另存到下面的输出目录。</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-h"><h2>每个文件的参数</h2><span class="sub" id="tcCount"></span></div>
|
||
<div id="tcRows"></div>
|
||
<div class="off" id="tcEmpty">还没有选文件</div>
|
||
<div class="bar">
|
||
<button class="ghost" onclick="applyTranscodeToAll()">把第一个的参数套到全部</button>
|
||
<span class="hint" id="tcTotal"></span>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-h"><h2>输出与执行</h2></div>
|
||
<div class="grid">
|
||
<div class="f"><label>输出目录(留空 = 成片文件夹)</label>
|
||
<div class="pathf">
|
||
<input type="text" id="tcOutDir" autocomplete="off" placeholder="留空用成片文件夹">
|
||
<button class="ghost" onclick="openPick('tcOutDir','dir')">浏览…</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="bar">
|
||
<button class="ghost" onclick="estimateTranscode()">重新估算体积</button>
|
||
<button id="tcGo" onclick="startTranscode()">开始转码</button>
|
||
<span class="hint" id="tcState"></span>
|
||
</div>
|
||
<div class="hint">转码和合并共用一个进度卡片,一次只跑一个文件,排着的按列表顺序来;进度见「监控」页,也可以在那里暂停和停止。</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 通知 -->
|
||
<section class="pane" data-pane="ntf">
|
||
<div class="card">
|
||
<div class="card-h"><h2>推送哪些事件</h2><span class="sub">下面两个渠道共用这份勾选</span></div>
|
||
<div class="sws">
|
||
<label class="sw"><input type="checkbox" id="notifyLiveOn"><span class="sw-i"></span>开播</label>
|
||
<label class="sw"><input type="checkbox" id="notifyLiveOff"><span class="sw-i"></span>下播</label>
|
||
<label class="sw"><input type="checkbox" id="notifyRecordStart"><span class="sw-i"></span>开始录制</label>
|
||
<label class="sw"><input type="checkbox" id="notifyMergeDone"><span class="sw-i"></span>合并完成</label>
|
||
<label class="sw"><input type="checkbox" id="notifyMergeFail"><span class="sw-i"></span>合并失败</label>
|
||
</div>
|
||
<div class="hint">推送失败只记警告,不影响录制。「合并失败」不含「文件里没有弹幕」和你自己点的手动停止。</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-h"><h2>渠道一 · QQ 机器人</h2><span class="sub">自建机器人,走 HTTP POST JSON</span></div>
|
||
<label class="sw"><input type="checkbox" id="notifyEnabled"><span class="sw-i"></span>启用这个渠道</label>
|
||
<div class="grid" style="margin-top:12px">
|
||
<div class="f"><label>通知推送地址(留空 = 不推送,POST JSON)</label><input type="text" id="notifyUrl" placeholder="例如 http://127.0.0.1:8080/acfunlive/notify"></div>
|
||
<div class="f"><label>推送口令(留空 = 不带,作为 Bearer 发送)</label><input type="text" id="notifyToken" placeholder="可选"></div>
|
||
<div class="f"><label>通知群号(随推送下发,机器人用它决定发到哪)</label><input type="text" id="notifyGroup" inputmode="numeric" placeholder="例如 777163254,留空则用机器人的默认群"></div>
|
||
</div>
|
||
<div class="hint">推送体是 json,字段 event / uid / name / title / url / file / group / text / time,text 可以直接发给群。</div>
|
||
<div class="bar">
|
||
<button class="ghost" onclick="testNotify(this,'qq')">测试连通</button>
|
||
<span class="hint">用上面填写的地址发一条测试推送,不用先保存;改完记得点右上角「保存设置」</span>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-h"><h2>渠道二 · MeoW</h2><span class="sub">手机上直接收通知</span></div>
|
||
<label class="sw"><input type="checkbox" id="meowEnabled"><span class="sw-i"></span>启用这个渠道</label>
|
||
<div class="grid" style="margin-top:12px">
|
||
<div class="f"><label>MeoW 昵称(接收者)</label><input type="text" id="meowNickname" placeholder="你在 MeoW 里的昵称"></div>
|
||
<div class="f"><label>消息类型</label><select id="meowMsgType">
|
||
<option value="text">text(纯文本)</option>
|
||
<option value="html">html(在 App 里渲染)</option>
|
||
</select></div>
|
||
<div class="f" id="meowHeightRow"><label>HTML 显示高度(像素)</label><input type="text" id="meowHtmlHeight" inputmode="numeric" placeholder="200"></div>
|
||
<div class="f"><label>通知图标 URL(可选)</label><input type="text" id="meowImgUrl" placeholder="建议 216×216 的 PNG,留空用 MeoW 默认图标"></div>
|
||
</div>
|
||
<div class="hint">推给 MeoW 里这个昵称,点开通知可以跳回直播间;接口有频控(3 秒 1 条、每分钟 15 条、每小时 60 条)。</div>
|
||
<div class="hint bad" id="meowBlocked" style="display:none"></div>
|
||
<div class="bar">
|
||
<button class="ghost" onclick="testNotify(this,'meow')">测试连通</button>
|
||
<span class="hint">用上面填写的昵称发一条测试推送,不用先保存;改完记得按回车或点右上角「保存设置」</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 文件 -->
|
||
<section class="pane" data-pane="file">
|
||
<div class="card">
|
||
<div class="card-h"><h2>文件管理</h2><span class="sub" id="diskInfo">正在读取磁盘信息…</span></div>
|
||
<div class="bar" style="margin-top:0">
|
||
<button class="ghost" onclick="loadFiles(this)">刷新文件列表</button>
|
||
<span class="hint" id="filesInfo"></span>
|
||
</div>
|
||
<div id="files" style="margin-top:8px"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 设置 -->
|
||
<section class="pane" data-pane="sys">
|
||
<div class="card">
|
||
<div class="card-h"><h2>访问方式</h2></div>
|
||
<label class="sw"><input type="checkbox" id="webExternal"><span class="sw-i"></span>允许外网访问(关闭时只有本机能打开本页)</label>
|
||
<div class="hint">开启后监听所有网卡,同一局域网的设备可用本机 IP 访问;修改后 web 服务会自动重启,页面会自动重连。</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-h"><h2>管理口令</h2><span class="sub">打开本页和管理录制都需要</span></div>
|
||
<div class="f"><label>当前口令(建议复制保存到别处)</label>
|
||
<div class="rowline">
|
||
<input type="text" id="authTokenView" readonly style="flex:1 1 200px;max-width:none">
|
||
<button class="ghost" onclick="copyToken()">复制</button>
|
||
</div>
|
||
</div>
|
||
<div class="f" style="margin-top:12px"><label>更换管理口令(至少 8 个字符)</label>
|
||
<div class="rowline">
|
||
<input type="text" id="authTokenNew" autocomplete="off" placeholder="输入新的管理口令" style="flex:1 1 200px;max-width:none">
|
||
<button class="ghost" onclick="changeToken()">更换</button>
|
||
</div>
|
||
</div>
|
||
<div class="hint">忘记口令时可以打开程序所在目录的 config.json,查看 authToken 字段。</div>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
<div id="toast"></div>
|
||
<div class="mask" id="auth">
|
||
<div class="box">
|
||
<h3>请输入管理口令</h3>
|
||
<p>管理口令在程序首次启动时自动生成并打印在运行日志里,也可以在程序所在目录的 config.json 的 authToken 字段查看。</p>
|
||
<input type="text" id="tokenInput" autocomplete="off" placeholder="管理口令">
|
||
<div style="margin-top:12px"><button onclick="login()">进入</button></div>
|
||
<div id="authMsg"></div>
|
||
</div>
|
||
</div>
|
||
<div class="mask" id="pick">
|
||
<div class="box pbox">
|
||
<h3 id="pickTitle">选择文件夹</h3>
|
||
<p id="pickDesc">逐级点选即可;也可以直接在地址栏里填或粘贴路径后按回车。选好后点下面的按钮填进设置框。</p>
|
||
<div class="pathf" style="margin-bottom:8px">
|
||
<button class="ghost" id="pickUp" onclick="pickUp()">↑ 上一级</button>
|
||
<input type="text" id="pickPath" spellcheck="false" autocomplete="off" placeholder="也可以直接填或粘贴路径,回车跳转">
|
||
<button class="ghost" onclick="pickGo(document.getElementById('pickPath').value)">转到</button>
|
||
</div>
|
||
<div class="roots" id="pickRoots"></div>
|
||
<div class="plist" id="pickList"></div>
|
||
<div class="hint" id="pickMsg"></div>
|
||
<div class="bar" style="margin-top:12px">
|
||
<label class="sw" style="margin-right:auto"><input type="checkbox" id="pickHidden"><span class="sw-i"></span>显示隐藏项</label>
|
||
<span class="hint" id="pickSel" style="margin:0"></span>
|
||
<button class="ghost" onclick="closePick()">取消</button>
|
||
<button id="pickOk" onclick="pickConfirm()">选用这个文件夹</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
var esc = function(s){ return (s||"").replace(/[&<>"]/g, function(c){ return {"&":"&","<":"<",">":">","\"":"""}[c]; }); };
|
||
var saving = false; // 正在保存设置时暂停定时刷新对表单的覆写
|
||
var stSig = ""; // 主播列表的数据摘要,没变化就不重建 DOM
|
||
var logOffset = "0"; // 已经读取到的日志偏移量
|
||
var token = localStorage.getItem("acfunlive_token") || ""; // 管理口令,存在浏览器本地
|
||
var authed = false; // 是否已通过口令校验,未通过时停止轮询
|
||
var tokenLoaded = false;
|
||
// 设置表单里的字段,元素 id 与 /api/config 的字段名一致
|
||
var settingFields = ["directory","autoMerge","mergeDelVideo","mergeDelDanmu","mergeOutputDir","mergeBitrate","mergeFps","danmuFontSize","defaultQualityPref","defaultBitrate","recordSubdir","mergeSubdir","ffmpegPath","recordFormat","notifyEnabled","notifyUrl","notifyToken","notifyGroup","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","notifyMergeFail","meowEnabled","meowNickname","meowMsgType","meowHtmlHeight","meowImgUrl","webExternal"];
|
||
var numberFields = {mergeBitrate:1, mergeFps:1, danmuFontSize:1, defaultBitrate:1, meowHtmlHeight:1};
|
||
// 用户改过、还没提交的字段。定时刷新会拿服务端的值填表单,不记下这些字段的话,
|
||
// 刚填好的数字 3 秒后会被刷回旧值,看起来就像「改了又自己恢复」
|
||
var dirty = {};
|
||
var lastSt = null; // 最近一次 /api/status 的结果,自动保存时用它填没提交的字段
|
||
function dirtyCount(){ return Object.keys(dirty).length; }
|
||
function markDirty(id){ if(id && !dirty[id]){ dirty[id] = true; syncDirtyUI(); } }
|
||
function dropDirty(id){ if(dirty[id]){ delete dirty[id]; syncDirtyUI(); } }
|
||
function syncDirtyUI(){
|
||
var n = dirtyCount();
|
||
var tip = document.getElementById("dirtyTip");
|
||
if(tip) tip.style.display = n ? "" : "none";
|
||
var b = document.getElementById("saveBtn");
|
||
if(b) b.classList.toggle("dirty", n > 0);
|
||
}
|
||
// 提交成功后才把对应的未保存标记清掉:值还是提交时那个才清,避免把提交期间的新改动也当成已保存
|
||
function clearSaved(body){
|
||
settingFields.forEach(function(id){
|
||
if(dirty[id] && String(fieldValue(id)).trim() === String(body[id]).trim()) delete dirty[id];
|
||
});
|
||
syncDirtyUI();
|
||
}
|
||
function fieldValue(id){
|
||
var el = document.getElementById(id);
|
||
if(!el) return "";
|
||
return el.type === "checkbox" ? !!el.checked : el.value;
|
||
}
|
||
// 按当前表单拼 /api/config 的请求体;serverForDirty 为真时,没提交的字段用服务端的值顶上
|
||
// (开关、下拉是改完立即保存的,不该被别的框里填了一半的内容连累到整批失败)
|
||
function settingsBody(serverForDirty){
|
||
var body = {};
|
||
settingFields.forEach(function(id){
|
||
var v = fieldValue(id);
|
||
if(typeof v === "string"){
|
||
v = v.trim();
|
||
if(numberFields[id]) v = parseInt(v, 10) || 0;
|
||
}
|
||
if(serverForDirty && dirty[id] && lastSt && lastSt[id] !== undefined) v = lastSt[id];
|
||
body[id] = v;
|
||
});
|
||
return body;
|
||
}
|
||
function setChk(id, v){ var el = document.getElementById(id); if(!saving && !dirty[id] && !el.matches(":focus")) el.checked = !!v; }
|
||
function setVal(id, v){ var el = document.getElementById(id); if(!saving && !dirty[id] && document.activeElement !== el) el.value = v; }
|
||
function show(el, v){ var e = document.getElementById(el); if(e) e.style.display = v ? "" : "none"; }
|
||
// 管理接口都要带上口令
|
||
function api(url, opt){
|
||
opt = opt || {};
|
||
var h = {};
|
||
var src = opt.headers || {};
|
||
for(var k in src){ h[k] = src[k]; }
|
||
// 请求头只接受 ISO-8859-1,口令里有中文的话浏览器会直接报错、请求发不出去,
|
||
// 所以统一先百分号编码,服务端两种写法都认
|
||
h["X-Auth-Token"] = encodeURIComponent(token);
|
||
opt.headers = h;
|
||
return fetch(url, opt);
|
||
}
|
||
// ========== 分区导航 ==========
|
||
var tabNames = {mon:"监控", rec:"录制", mrg:"合并", trc:"转码", ntf:"通知", file:"文件", sys:"设置"};
|
||
var curTab = "";
|
||
function showTab(name){
|
||
if(!tabNames[name]) name = "mon";
|
||
if(curTab === name) return;
|
||
curTab = name;
|
||
var tabs = document.querySelectorAll(".tab");
|
||
for(var i = 0; i < tabs.length; i++){ tabs[i].classList.toggle("on", tabs[i].dataset.tab === name); }
|
||
var panes = document.querySelectorAll(".pane");
|
||
for(var j = 0; j < panes.length; j++){ panes[j].classList.toggle("on", panes[j].dataset.pane === name); }
|
||
try{ localStorage.setItem("acfunlive_tab", name); }catch(e){}
|
||
// 地址栏留个 #分区,刷新和收藏都能回到同一个分区(replaceState 不往历史里塞记录)
|
||
if(location.hash.slice(1) !== name){
|
||
try{ history.replaceState(null, "", "#" + name); }catch(e){ location.hash = name; }
|
||
}
|
||
// 切过去的时候顺手刷新那个分区里的实时数据
|
||
if(name === "file" && authed) loadFiles();
|
||
if(name === "mrg" && authed) fillMergePickers();
|
||
// 转码页的文件下拉靠文件列表填,切过去时列表还是空的就先拉一次
|
||
if(name === "trc" && authed){
|
||
if(fileList.length) fillTranscodePick(); else loadFiles();
|
||
}
|
||
}
|
||
document.getElementById("tabs").addEventListener("click", function(e){
|
||
var t = e.target.closest(".tab");
|
||
if(t) showTab(t.dataset.tab);
|
||
});
|
||
window.addEventListener("hashchange", function(){ showTab(location.hash.slice(1)); });
|
||
function showLogin(text){
|
||
authed = false;
|
||
document.getElementById("auth").classList.add("on");
|
||
document.getElementById("authMsg").textContent = text || "";
|
||
var ti = document.getElementById("tokenInput");
|
||
if(!ti.value) ti.value = token;
|
||
}
|
||
function hideLogin(){
|
||
authed = true;
|
||
document.getElementById("auth").classList.remove("on");
|
||
document.getElementById("authMsg").textContent = "";
|
||
}
|
||
async function login(){
|
||
var m = document.getElementById("authMsg");
|
||
var t = document.getElementById("tokenInput").value.trim();
|
||
if(!t){ m.textContent = "请输入管理口令"; return; }
|
||
token = t;
|
||
authed = true;
|
||
var r = null;
|
||
try{ r = await api("/api/auth"); }catch(e){}
|
||
if(r && r.ok){
|
||
localStorage.setItem("acfunlive_token", token);
|
||
hideLogin();
|
||
tokenLoaded = false;
|
||
refresh();
|
||
}else{
|
||
authed = false;
|
||
m.textContent = r ? "管理口令不正确" : "无法连接到服务器";
|
||
}
|
||
}
|
||
function copyToken(){
|
||
var el = document.getElementById("authTokenView");
|
||
if(!el.value) return;
|
||
el.removeAttribute("readonly");
|
||
el.select();
|
||
el.setSelectionRange(0, 999);
|
||
var ok = false;
|
||
try{ ok = document.execCommand("copy"); }catch(e){}
|
||
el.setAttribute("readonly", "readonly");
|
||
// 局域网 http 下 navigator.clipboard 不可用,上面的 execCommand 才是主要手段
|
||
if(window.isSecureContext && navigator.clipboard){
|
||
try{ navigator.clipboard.writeText(el.value); ok = true; }catch(e){}
|
||
}
|
||
showMsg(ok, ok ? "已复制管理口令" : "自动复制失败,请手动选中复制");
|
||
}
|
||
async function changeToken(){
|
||
var t = document.getElementById("authTokenNew").value.trim();
|
||
if(t.length < 8){ showMsg(false, "管理口令至少 8 个字符"); return; }
|
||
var r = null;
|
||
try{
|
||
r = await api("/api/auth/token", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({token: t})});
|
||
}catch(e){}
|
||
if(!r || !r.ok){ showMsg(false, r ? await r.text() : "连接失败"); return; }
|
||
token = t;
|
||
localStorage.setItem("acfunlive_token", token);
|
||
document.getElementById("authTokenView").value = t;
|
||
document.getElementById("authTokenNew").value = "";
|
||
hideLogin();
|
||
showMsg(true, "管理口令已更换,本机已自动更新");
|
||
refresh();
|
||
}
|
||
async function refresh(){
|
||
if(!authed) return;
|
||
try{
|
||
var r = await api("/api/status");
|
||
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||
if(!r.ok) return;
|
||
var st = await r.json();
|
||
lastSt = st;
|
||
hideLogin();
|
||
// 口令只在登录后拉取一次,填进设置区的只读框
|
||
if(!tokenLoaded){
|
||
tokenLoaded = true;
|
||
api("/api/auth/token").then(function(x){ return x.ok ? x.json() : null; }).then(function(d){
|
||
if(d && d.token) document.getElementById("authTokenView").value = d.token;
|
||
}).catch(function(){});
|
||
// 顺便读一次文件列表,不用等用户点刷新
|
||
loadFiles();
|
||
}
|
||
setVal("directory", st.directory||"");
|
||
setVal("mergeOutputDir", st.mergeOutputDir||"");
|
||
setVal("mergeBitrate", st.mergeBitrate||0);
|
||
setVal("mergeFps", st.mergeFps||0);
|
||
setVal("danmuFontSize", st.danmuFontSize||0);
|
||
setVal("defaultQualityPref", st.defaultQualityPref||"second");
|
||
setVal("defaultBitrate", st.defaultBitrate||0);
|
||
toggleDefaultBr();
|
||
setVal("recordSubdir", st.recordSubdir||"record");
|
||
setVal("mergeSubdir", st.mergeSubdir||"merged");
|
||
setVal("ffmpegPath", st.ffmpegPath||"");
|
||
setVal("recordFormat", st.recordFormat||"mp4");
|
||
setVal("notifyUrl", st.notifyUrl||"");
|
||
setVal("notifyToken", st.notifyToken||"");
|
||
setVal("notifyGroup", st.notifyGroup||"");
|
||
setChk("notifyEnabled", st.notifyEnabled);
|
||
setChk("notifyLiveOn", st.notifyLiveOn);
|
||
setChk("notifyLiveOff", st.notifyLiveOff);
|
||
setChk("notifyRecordStart", st.notifyRecordStart);
|
||
setChk("notifyMergeDone", st.notifyMergeDone);
|
||
setChk("notifyMergeFail", st.notifyMergeFail);
|
||
setChk("meowEnabled", st.meowEnabled);
|
||
setVal("meowNickname", st.meowNickname||"");
|
||
setVal("meowMsgType", st.meowMsgType||"text");
|
||
setVal("meowHtmlHeight", st.meowHtmlHeight||200);
|
||
setVal("meowImgUrl", st.meowImgUrl||"");
|
||
toggleMeowHeight();
|
||
var mb = document.getElementById("meowBlocked");
|
||
if(st.meowBlocked){ mb.style.display = ""; mb.textContent = "当前推不出去:" + st.meowBlocked; }
|
||
else show("meowBlocked", false);
|
||
setChk("webExternal", st.webExternal);
|
||
setChk("autoMerge", st.autoMerge);
|
||
setChk("mergeDelVideo", st.mergeDelVideo);
|
||
setChk("mergeDelDanmu", st.mergeDelDanmu);
|
||
syncMergeState(st);
|
||
syncTranscodeState(st);
|
||
// 顶栏的状态摘要
|
||
var sl = st.streamers||[];
|
||
var rec = 0, live = 0;
|
||
sl.forEach(function(s){ if(s.recordActive || s.danmuActive) rec++; else if(s.live) live++; });
|
||
var ts = document.getElementById("topStat");
|
||
if(rec) ts.innerHTML = '<i class="dot rec"></i>录制中 ' + rec;
|
||
else if(live) ts.innerHTML = '<i class="dot live"></i>直播中 ' + live;
|
||
else if(sl.length) ts.innerHTML = '<i class="dot"></i>空闲';
|
||
else ts.innerHTML = "";
|
||
var rows = sl.map(function(s){
|
||
var badges = "";
|
||
if(s.recordActive) badges += '<span class="badge on">录播中</span>';
|
||
if(s.danmuActive) badges += '<span class="badge rec">弹幕中</span>';
|
||
if(!badges) badges = s.live ? '<span class="badge on">直播中</span>' : '<span class="off">离线</span>';
|
||
var title = esc(s.title);
|
||
var qs = s.qualities || [];
|
||
// 档位是「按实际档位里的第几档」来选的:四种偏好永远都在(不开播也能先设),
|
||
// 读到档位表后把对应到的档位名标在后面
|
||
var nameFor = function(p){
|
||
if(p === "custom") return s.qualityName || "";
|
||
if(!qs.length) return "";
|
||
if(p === "top") return qs[0].name;
|
||
if(p === "lowest") return qs[qs.length-1].name;
|
||
return qs[qs.length > 1 ? 1 : 0].name; // 第二高
|
||
};
|
||
var cur = s.qualityPref || "second";
|
||
var qOpts = [["top", "最高档"], ["second", "第二高"], ["lowest", "最低档"], ["custom", "自定义"]];
|
||
var opts = qOpts.map(function(o){
|
||
var label = o[1];
|
||
if(o[0] === "custom"){
|
||
// 自定义:标出当前上限对应到哪个档位
|
||
if(s.bitrate > 0) label += "(≤" + s.bitrate + "K" + (s.qualityName ? " = " + esc(s.qualityName) : "") + ")";
|
||
else label += qs.length ? "(填个码率上限)" : "(填个码率上限,开播后对应)";
|
||
}else{
|
||
var n = nameFor(o[0]);
|
||
if(n) label += "(" + esc(n) + ")";
|
||
}
|
||
return '<option value="' + o[0] + '"' + (o[0] === cur ? " selected" : "") + '>' + label + '</option>';
|
||
}).join("");
|
||
var tip = qs.length ? '录制档位:按这次直播的实际档位对应,正在录制时换档会自动重录'
|
||
: '录制档位:还没读到档位表(开播后才拿得到),随时可以先设好;点右边按钮能立刻读一次';
|
||
// data-qbitrate 只是给排查看的:真正「当前生效的档位码率」会从服务端状态里取
|
||
var brCell = '<select class="brsel" data-uid="' + s.uid + '" data-qbitrate="' + (s.qualityBitrate || 0)
|
||
+ '" title="' + tip + '">' + opts + '</select>';
|
||
if(cur === "custom"){
|
||
brCell += '<input type="text" class="brnum" data-uid="' + s.uid + '" inputmode="numeric" value="'
|
||
+ (s.bitrate || 0) + '" placeholder="Kbps" title="自定义:取不超过这个码率的最高档">';
|
||
}
|
||
brCell += '<button class="ghost mini" onclick="loadQualities(' + s.uid + ')" title="去直播间接一次码率档位">档位</button>';
|
||
return '<div class="row">'
|
||
+ '<span class="uid">uid ' + s.uid + '</span>'
|
||
+ '<span class="name">' + (esc(s.name) || "uid " + s.uid) + '</span>'
|
||
+ '<label class="pill"><input type="checkbox" data-uid="' + s.uid + '" data-k="danmu"' + (s.danmu?" checked":"") + '>弹幕</label>'
|
||
+ '<label class="pill"><input type="checkbox" data-uid="' + s.uid + '" data-k="record"' + (s.record?" checked":"") + '>录播</label>'
|
||
+ brCell
|
||
+ '<span class="state">' + badges + '</span>'
|
||
+ '<span class="title"' + (title?' title="' + title + '"':'') + '>' + title + '</span>'
|
||
+ '<span class="ops">'
|
||
+ '<button class="ghost" onclick="ctl(' + s.uid + ',\'stop\')">停止</button>'
|
||
+ '<button class="ghost" onclick="ctl(' + s.uid + ',\'restart\')">重新开始</button>'
|
||
+ '<button class="ghost" onclick="delUid(' + s.uid + ')">删除</button></span></div>';
|
||
}).join("");
|
||
var list = document.getElementById("st");
|
||
var sig = JSON.stringify(st.streamers);
|
||
// 只有「正在列表里的文本框里打字」才跳过重建:下拉和勾选都是改完立即提交的,
|
||
// 一直卡着不重建会让「选了自定义该出现的码率框」「录播中的徽标」迟迟不刷新
|
||
var ae = document.activeElement;
|
||
var typing = !!(ae && list.contains(ae) && ae.tagName === "INPUT" && ae.type !== "checkbox");
|
||
if(sig !== stSig && !typing){
|
||
stSig = sig;
|
||
// 重建前记下用户改过、还没提交的自定义码率,建完抄回去(填错的不用留)
|
||
var keepNum = {};
|
||
Array.prototype.forEach.call(list.querySelectorAll("input.brnum"), function(el){
|
||
var v = parseInt(el.value.trim(), 10);
|
||
if(el.dataset.dirty === "1" && !isNaN(v) && v >= 0) keepNum[el.dataset.uid] = el.value;
|
||
});
|
||
list.innerHTML = rows || '<div class="off">列表为空,在上面填 uid 添加</div>';
|
||
Object.keys(keepNum).forEach(function(uid){
|
||
var el = list.querySelector('input.brnum[data-uid="' + uid + '"]');
|
||
if(el){ el.value = keepNum[uid]; el.dataset.dirty = "1"; }
|
||
});
|
||
}
|
||
// 开播了但还没有档位缓存的,自动去读一次,让档位下拉能标出对应关系
|
||
autoLoadQualities(st.streamers);
|
||
var lr = await api("/api/log?since=" + logOffset);
|
||
if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||
var off = lr.headers.get("X-Log-Offset");
|
||
var chunk = await lr.text();
|
||
if(off) logOffset = off;
|
||
if(chunk){
|
||
var p = document.getElementById("log");
|
||
var stick = p.scrollHeight - p.scrollTop - p.clientHeight < 40;
|
||
p.textContent += chunk;
|
||
// 页面里保留的日志太长会拖慢浏览器,只留末尾一段
|
||
if(p.textContent.length > 300000) p.textContent = p.textContent.slice(-150000);
|
||
if(stick) p.scrollTop = p.scrollHeight;
|
||
}
|
||
}catch(e){}
|
||
}
|
||
// 右下角弹一条提示,成功 2.6 秒、失败 6 秒
|
||
function showMsg(ok, text){
|
||
var w = document.getElementById("toast");
|
||
if(!w){ return; }
|
||
var d = document.createElement("div");
|
||
d.className = "toast" + (ok ? "" : " bad");
|
||
d.textContent = text;
|
||
w.appendChild(d);
|
||
while(w.children.length > 4){ w.removeChild(w.firstChild); }
|
||
var life = ok ? 2600 : 6000;
|
||
setTimeout(function(){
|
||
d.classList.add("out");
|
||
setTimeout(function(){ if(d.parentNode) d.parentNode.removeChild(d); }, 220);
|
||
}, life);
|
||
}
|
||
async function post(url, body, errPrefix){
|
||
saving = true;
|
||
try{
|
||
var r = await api(url, {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)});
|
||
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return false; }
|
||
if(!r.ok) showMsg(false, (errPrefix || "") + await r.text());
|
||
return r.ok;
|
||
}finally{
|
||
saving = false;
|
||
refresh();
|
||
// 保存目录、子文件名可能刚被改过,顺手刷新一遍文件列表
|
||
loadFiles();
|
||
}
|
||
}
|
||
async function saveSettings(b, autoSave){
|
||
if(b) b.disabled = true;
|
||
var body = settingsBody(!!autoSave);
|
||
var ok = await post("/api/config", body, autoSave ? "保存失败,这次改动没生效:" : "");
|
||
if(ok){
|
||
clearSaved(body);
|
||
showMsg(true, "已保存");
|
||
}
|
||
if(b) b.disabled = false;
|
||
}
|
||
// 勾选类设置修改后立即保存,避免被定时刷新还原
|
||
["autoMerge","mergeDelVideo","mergeDelDanmu","webExternal","notifyEnabled","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","notifyMergeFail","meowEnabled"].forEach(function(id){
|
||
document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); });
|
||
});
|
||
// 下拉框改完也立即保存
|
||
["recordFormat","meowMsgType"].forEach(function(id){
|
||
document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); });
|
||
});
|
||
// 只有「自定义」才需要填码率上限
|
||
function toggleDefaultBr(){
|
||
document.getElementById("defBrRow").style.display =
|
||
document.getElementById("defaultQualityPref").value === "custom" ? "" : "none";
|
||
}
|
||
document.getElementById("defaultQualityPref").addEventListener("change", function(){
|
||
var el = document.getElementById("defaultBitrate");
|
||
// 切到自定义时补一个能用的初始值(只在用户主动切换时补,定时刷新不碰它),
|
||
// 不然新主播会按 0 落到最低档
|
||
if(this.value === "custom" && !(parseInt(el.value.trim(), 10) > 0)){
|
||
el.value = 3000;
|
||
dropDirty("defaultBitrate");
|
||
}
|
||
toggleDefaultBr();
|
||
dropDirty("defaultQualityPref");
|
||
saveSettings(null, true);
|
||
});
|
||
// HTML 高度只对 html 类型有意义,选 text 时收起来
|
||
function toggleMeowHeight(){
|
||
var isHTML = document.getElementById("meowMsgType").value === "html";
|
||
document.getElementById("meowHeightRow").style.display = isHTML ? "" : "none";
|
||
}
|
||
document.getElementById("meowMsgType").addEventListener("change", toggleMeowHeight);
|
||
// 文本类字段一改就记成「未保存」,定时刷新不再盖掉它,直到保存成功
|
||
document.addEventListener("input", function(e){
|
||
var el = e.target;
|
||
if(!el) return;
|
||
if(el.classList && el.classList.contains("br")){ el.dataset.dirty = "1"; return; }
|
||
if(el.classList && el.classList.contains("brnum")){ el.dataset.dirty = "1"; return; }
|
||
if(el.id && settingFields.indexOf(el.id) >= 0) markDirty(el.id);
|
||
});
|
||
// 回车等于点保存:输入框不在 form 里,浏览器默认不会提交,很容易以为已经生效了
|
||
document.addEventListener("keydown", function(e){
|
||
if(e.key !== "Enter" || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
|
||
var el = e.target;
|
||
if(!el || el.tagName !== "INPUT" || el.type !== "text" || el.readOnly) return;
|
||
if(el.classList.contains("br") || el.classList.contains("brnum")){ el.blur(); return; } // 档位相关的框失焦即提交
|
||
if(el.id === "newuid"){ e.preventDefault(); addUid(); return; }
|
||
if(el.id === "authTokenNew"){ e.preventDefault(); changeToken(); return; }
|
||
// 合并 / 转码那一行一行的参数框不是设置项:回车用来触发检查或重新估算,别去点保存
|
||
if(el.closest && el.closest(".trow")){
|
||
e.preventDefault();
|
||
if(el.closest("#mrgJobs")) checkMerge();
|
||
else if(el.closest("#tcRows")) estimateTranscode();
|
||
return;
|
||
}
|
||
if(settingFields.indexOf(el.id) >= 0){ e.preventDefault(); saveSettings(null); }
|
||
});
|
||
// 还有没保存的改动时提醒一句,别直接关掉页面
|
||
window.addEventListener("beforeunload", function(e){
|
||
if(dirtyCount() > 0){ e.preventDefault(); e.returnValue = ""; return ""; }
|
||
});
|
||
var fileList = [];
|
||
function fmtSize(n){
|
||
if(!n) return "0 B";
|
||
if(n >= 1073741824) return (n/1073741824).toFixed(2) + " GB";
|
||
if(n >= 1048576) return (n/1048576).toFixed(1) + " MB";
|
||
if(n >= 1024) return (n/1024).toFixed(0) + " KB";
|
||
return n + " B";
|
||
}
|
||
function fmtTime(ts){
|
||
var d = new Date(ts*1000);
|
||
function p(x){ return (x<10?"0":"")+x; }
|
||
return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate())+" "+p(d.getHours())+":"+p(d.getMinutes());
|
||
}
|
||
function fileUrl(f){
|
||
return "/api/file?dir=" + encodeURIComponent(f.dir) + "&name=" + encodeURIComponent(f.name) + "&token=" + encodeURIComponent(token);
|
||
}
|
||
async function loadFiles(b){
|
||
if(b) b.disabled = true;
|
||
var r = null;
|
||
try{ r = await api("/api/files"); }catch(e){}
|
||
if(b) b.disabled = false;
|
||
if(!r){ showMsg(false, "读取文件列表失败"); return; }
|
||
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||
if(!r.ok){ showMsg(false, await r.text()); return; }
|
||
var d = await r.json();
|
||
fileList = d.files || [];
|
||
recDir = d.recordDir || "";
|
||
fillMergePickers();
|
||
fillTranscodePick();
|
||
document.getElementById("diskInfo").textContent =
|
||
"磁盘剩余 " + fmtSize(d.free) + " / 共 " + fmtSize(d.total) +
|
||
" 录播:" + d.recordDir + " 成片:" + d.mergeDir;
|
||
var used = 0;
|
||
fileList.forEach(function(f){ used += f.size; });
|
||
document.getElementById("filesInfo").textContent =
|
||
fileList.length ? ("共 " + fileList.length + " 个文件,占用 " + fmtSize(used)) : "没有文件";
|
||
document.getElementById("files").innerHTML = fileList.length ? fileList.map(function(f, i){
|
||
var canPlay = /\.(mp4|m4v|webm|ogv)$/i.test(f.name);
|
||
var u = esc(fileUrl(f));
|
||
return '<div class="row">'
|
||
+ '<span class="name" title="' + esc(f.name) + '">' + esc(f.name) + '</span>'
|
||
+ '<span class="uid">' + (f.dir === "merge" ? "成片" : "录播") + '</span>'
|
||
+ '<span class="uid">' + fmtSize(f.size) + '</span>'
|
||
+ '<span class="uid">' + fmtTime(f.modTime) + '</span>'
|
||
+ (f.inUse ? ' <span class="badge on">写入中</span>' : '')
|
||
+ '<span class="ops">'
|
||
+ (canPlay ? '<a class="ghost" href="' + u + '&inline=1" target="_blank" rel="noopener">播放</a>' : '')
|
||
+ '<a class="ghost" href="' + u + '">下载</a>'
|
||
+ (f.inUse ? '' : '<button class="ghost" onclick="delFile(' + i + ')">删除</button>')
|
||
+ '</span></div>';
|
||
}).join("") : '<div class="off">没有文件</div>';
|
||
}
|
||
async function delFile(i){
|
||
var f = fileList[i];
|
||
if(!f) return;
|
||
if(!confirm("确定删除 " + f.name + " ?删除后无法恢复。")) return;
|
||
var r = null;
|
||
try{
|
||
r = await api("/api/file/remove", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({dir: f.dir, name: f.name})});
|
||
}catch(e){}
|
||
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||
showMsg(!!(r && r.ok), (r && r.ok) ? "已删除" : ("删除失败:" + (r ? await r.text() : "连接失败")));
|
||
loadFiles();
|
||
}
|
||
// ========== 路径选择器 ==========
|
||
// 目录 / 文件框两种填法并存:自己敲或粘贴,或者点「浏览…」逐级点选
|
||
var pickField = ""; // 选中的路径填回哪个输入框
|
||
var pickKind = "dir"; // dir 选文件夹,file 选文件
|
||
var pickCur = ""; // 当前所在目录
|
||
var pickParent = ""; // 上一级
|
||
var pickFile = ""; // 单选时选中的那个
|
||
var pickMulti = false; // 多选模式(视频框这种一行一个、可以选多个的)
|
||
var pickCb = null; // 选完后交给它的回调,不为空时不再往输入框里填
|
||
var pickSel = []; // 多选已勾选的绝对路径,换目录也不清,所以能跨文件夹/跨盘攒
|
||
var recDir = ""; // 录播目录,挑弹幕和视频时从它开始
|
||
var lastPickKey = "", lastPickAt = 0; // 列表里连点同一项只算一次
|
||
var lastNavAt = 0; // 刚跳转过目录的这段时间里忽略列表上的点击,双击文件夹才不会连进两层
|
||
|
||
// cb 不为空时选完不填框,而是把选中的路径交给它(手动合并、转码这种一行一个的列表用)
|
||
function openPick(id, kind, multi, cb){
|
||
var el = id ? document.getElementById(id) : null;
|
||
pickField = id;
|
||
pickKind = kind === "file" ? "file" : "dir";
|
||
pickMulti = !!multi && pickKind === "file";
|
||
pickCb = cb || null;
|
||
pickFile = "";
|
||
pickSel = [];
|
||
document.getElementById("pickTitle").textContent = pickMulti ? "选择文件(可多选)" : (pickKind === "file" ? "选择文件" : "选择文件夹");
|
||
document.getElementById("pickDesc").textContent = pickMulti
|
||
? "点一下文件名即勾选,再点一下取消;可以进到别的文件夹继续勾。选好后点下面的按钮追加到框里,已经填好的内容不会被清掉。"
|
||
: "逐级点选即可;也可以直接在地址栏里填或粘贴路径后按回车。选好后点下面的按钮填进设置框。";
|
||
document.getElementById("pickHidden").checked = false;
|
||
document.getElementById("pick").classList.toggle("multi", pickMulti);
|
||
document.getElementById("pick").classList.add("on");
|
||
// 多行框(视频)取第一行当起点,整段拿去当路径会解析出一个乱七八糟的目录
|
||
var start = el ? el.value.split("\n")[0].trim() : "";
|
||
// 框里是文件名而不是路径时(弹幕名、视频名这种)不算路径:这两个框从录播目录开始,其它从程序所在目录开始
|
||
if(start.indexOf("/") < 0 && start.indexOf("\\") < 0) start = (id === "mrgDanmu" || !id) ? recDir : "";
|
||
pickGo(start);
|
||
}
|
||
function closePick(){ document.getElementById("pick").classList.remove("on"); }
|
||
function pickUp(){ if(pickParent) pickGo(pickParent); }
|
||
async function pickGo(path){
|
||
lastNavAt = Date.now();
|
||
var msg = document.getElementById("pickMsg");
|
||
msg.textContent = "";
|
||
var base = "/api/browse?kind=" + pickKind + (document.getElementById("pickHidden").checked ? "&hidden=1" : "");
|
||
var d = null, fellBack = false;
|
||
// 框里填的是文件、或者目录根本不存在时,往上退到能打开的一层,别停在空列表上
|
||
for(var i = 0; i < 6; i++){
|
||
var url = base + (path ? "&path=" + encodeURIComponent(path) : "");
|
||
var r = null;
|
||
try{ r = await api(url); }catch(e){}
|
||
if(!r){ msg.textContent = "读取失败,连接不上服务器"; return; }
|
||
if(r.status === 401){ closePick(); showLogin("管理口令已失效,请重新输入"); return; }
|
||
if(!r.ok){ msg.textContent = "读取失败:" + await r.text(); return; }
|
||
d = await r.json();
|
||
if(!d.error || !d.parent) break;
|
||
path = d.parent;
|
||
fellBack = true;
|
||
}
|
||
var items = d.entries || [];
|
||
pickCur = d.path || "";
|
||
pickParent = d.parent || "";
|
||
// 单选时换了目录、之前选中的文件就不在当前视图里了,清掉;多选是故意跨目录攒的,不动
|
||
if(!pickMulti && pickFile && !items.some(function(x){ return x.path === pickFile; })) pickFile = "";
|
||
document.getElementById("pickPath").value = pickCur;
|
||
document.getElementById("pickUp").disabled = !pickParent;
|
||
document.getElementById("pickRoots").innerHTML = (d.roots || []).map(function(x){
|
||
return '<button type="button" data-path="' + esc(x.path) + '">' + esc(x.name) + '</button>';
|
||
}).join("");
|
||
var list = document.getElementById("pickList");
|
||
if(!items.length){
|
||
list.innerHTML = '<div class="off">' + (pickKind === "file" ? "这里没有可选项" : "这里没有子文件夹") + '</div>';
|
||
}else{
|
||
list.innerHTML = items.map(function(x){
|
||
var on = "";
|
||
if(!x.dir){
|
||
on = (pickMulti ? pickSel.indexOf(x.path) >= 0 : x.path === pickFile) ? " on" : "";
|
||
}
|
||
return '<div class="pi' + (x.dir ? " pi-dir" : "") + on + '" data-path="' + esc(x.path) + '" data-dir="' + (x.dir ? "1" : "0") + '">'
|
||
+ '<span class="pi-c"></span>'
|
||
+ '<span class="pi-n" title="' + esc(x.path) + '">' + esc(x.name) + '</span>'
|
||
+ '<span class="pi-m">' + (x.dir ? "文件夹" : fmtSize(x.size)) + '</span></div>';
|
||
}).join("");
|
||
}
|
||
updatePickBar();
|
||
if(d.error){
|
||
msg.textContent = d.error;
|
||
}else if(fellBack){
|
||
msg.textContent = "原来填的位置打不开,已经跳到能打开的上级目录";
|
||
}else if(d.truncated){
|
||
msg.textContent = "条目太多,只列出了前 2000 项";
|
||
}
|
||
}
|
||
// 按钮文案和「已选 N 项」都跟着当前模式与勾选情况走
|
||
function updatePickBar(){
|
||
var ok = document.getElementById("pickOk");
|
||
var sel = document.getElementById("pickSel");
|
||
if(pickMulti){
|
||
ok.disabled = !pickSel.length;
|
||
ok.textContent = pickSel.length ? ("选用这 " + pickSel.length + " 个文件") : "选用选中的文件";
|
||
sel.innerHTML = pickSel.length
|
||
? ("已选 " + pickSel.length + " 项(<span class=\"lnk\" onclick=\"pickClearSel()\">清空</span>)")
|
||
: "点文件名即勾选,可换目录继续勾";
|
||
}else{
|
||
ok.disabled = pickKind === "file" && !pickFile;
|
||
ok.textContent = pickKind === "file" ? "选用这个文件" : "选用这个文件夹";
|
||
sel.innerHTML = "";
|
||
}
|
||
}
|
||
// 只重画高亮,不重新拉目录
|
||
function paintPickSel(){
|
||
var all = document.querySelectorAll("#pickList .pi");
|
||
for(var i = 0; i < all.length; i++){
|
||
var it = all[i];
|
||
if(it.dataset.dir === "1") continue;
|
||
it.classList.toggle("on", pickMulti ? pickSel.indexOf(it.dataset.path) >= 0 : it.dataset.path === pickFile);
|
||
}
|
||
updatePickBar();
|
||
}
|
||
function pickClearSel(){ pickSel = []; paintPickSel(); }
|
||
// 录播目录下的文件就只填文件名,框里清爽,保存目录以后改了也还认得;别处的填绝对路径
|
||
function pickFillValue(p){
|
||
if(!recDir) return p;
|
||
var parent = p.replace(/[\\/][^\\/]*$/, "");
|
||
if(parent.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() === recDir.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()){
|
||
return p.split(/[\\/]/).pop();
|
||
}
|
||
return p;
|
||
}
|
||
function pickConfirm(){
|
||
var el = pickField ? document.getElementById(pickField) : null;
|
||
// 多选模式下要先把路径换成「录播目录里的只写文件名」,填框和走回调都一样
|
||
if(pickMulti){
|
||
if(!pickSel.length) return;
|
||
var picked = pickSel.map(function(p){ return pickFillValue(p); });
|
||
if(pickCb){
|
||
closePick();
|
||
pickCb(picked);
|
||
return;
|
||
}
|
||
if(el){
|
||
// 追加:已有行原样留着,只把这次勾的加进去(同名/同路径不重复)
|
||
var lines = el.value.split("\n").map(function(s){ return s.trim(); }).filter(function(s){ return s; });
|
||
pickSel.forEach(function(p){
|
||
var name = pickFillValue(p);
|
||
if(lines.indexOf(name) < 0 && lines.indexOf(p) < 0) lines.push(name);
|
||
});
|
||
el.value = lines.join("\n");
|
||
// 手动合并的视频/弹幕框不进配置,标了「未保存」也永远清不掉,所以只对设置表单里的框标记
|
||
if(settingFields.indexOf(pickField) >= 0) markDirty(pickField);
|
||
}
|
||
closePick();
|
||
return;
|
||
}
|
||
var v = pickKind === "file" ? pickFile : pickCur;
|
||
if(!v) return;
|
||
if(pickKind === "file") v = pickFillValue(v);
|
||
if(pickCb){
|
||
closePick();
|
||
pickCb([v]);
|
||
return;
|
||
}
|
||
if(el){
|
||
el.value = v;
|
||
if(settingFields.indexOf(pickField) >= 0) markDirty(pickField);
|
||
}
|
||
closePick();
|
||
}
|
||
document.getElementById("pickList").addEventListener("click", function(e){
|
||
var it = e.target.closest(".pi");
|
||
if(!it) return;
|
||
if(Date.now() - lastNavAt < 350) return;
|
||
// 双击时浏览器会先发两次 click,同一条目短时间内重复点击只算一次
|
||
if(it.dataset.path === lastPickKey && Date.now() - lastPickAt < 350) return;
|
||
lastPickKey = it.dataset.path;
|
||
lastPickAt = Date.now();
|
||
if(it.dataset.dir === "1"){ pickGo(it.dataset.path); return; }
|
||
if(pickMulti){
|
||
var i = pickSel.indexOf(it.dataset.path);
|
||
if(i >= 0) pickSel.splice(i, 1); else pickSel.push(it.dataset.path);
|
||
}else{
|
||
pickFile = it.dataset.path;
|
||
}
|
||
paintPickSel();
|
||
});
|
||
// 文件双击直接选用(多选时把双击的这个也算上,再连同已勾的一起追加)
|
||
document.getElementById("pickList").addEventListener("dblclick", function(e){
|
||
var it = e.target.closest(".pi");
|
||
if(!it || it.dataset.dir === "1") return;
|
||
if(pickMulti && pickSel.indexOf(it.dataset.path) < 0) pickSel.push(it.dataset.path);
|
||
pickConfirm();
|
||
});
|
||
document.getElementById("pickRoots").addEventListener("click", function(e){
|
||
var b = e.target.closest("button");
|
||
if(b) pickGo(b.dataset.path);
|
||
});
|
||
document.getElementById("pickHidden").addEventListener("change", function(){ pickGo(pickCur); });
|
||
document.getElementById("pickPath").addEventListener("keydown", function(e){
|
||
if(e.key === "Enter"){ e.preventDefault(); pickGo(this.value.trim()); }
|
||
});
|
||
document.addEventListener("keydown", function(e){
|
||
if(e.key === "Escape" && document.getElementById("pick").classList.contains("on")) closePick();
|
||
});
|
||
// ========== 手动合并 ==========
|
||
var mrgBusy = false; // 是否正在合并,跟着 /api/status 一起刷新
|
||
var mrgJobs = []; // 待合并的任务:每个任务一组视频 + 自己的弹幕和参数
|
||
var mrgSeq = 0; // 任务编号,重画行时靠它认回原来的数据
|
||
|
||
// 文件名是「2006-01-02 15-04-05 主播名 标题.ext」,开头的录制时刻就在这里
|
||
function nameStartTime(name){
|
||
var base = name.replace(/\.[^.]+$/, "");
|
||
if(base.length < 19) return 0;
|
||
var ms = Date.parse(base.slice(0,10).replace(/-/g,"/") + " " + base.slice(11,19).replace(/-/g,":"));
|
||
return isNaN(ms) ? 0 : ms;
|
||
}
|
||
// 去掉开头的时间和扩展名,剩下的就是这场的主播名 + 标题
|
||
function nameSubject(name){
|
||
var base = name.replace(/\.[^.]+$/, "");
|
||
return base.length > 19 ? base.slice(19) : base;
|
||
}
|
||
// 把选中的视频按场次归组:主题相同、且相邻两段录制时间差在 2 小时内的算同一场
|
||
// (同一天连着开两场、标题又一样的时候靠时间差分开,不然两场会被拼成一个成片)
|
||
function groupVideos(names){
|
||
var items = names.slice().sort();
|
||
var groups = [], prevKey = null, prevT = 0;
|
||
items.forEach(function(n){
|
||
var key = nameSubject(n), t = nameStartTime(n);
|
||
var gap = (t && prevT) ? Math.abs(t - prevT) : 0;
|
||
if(!groups.length || key !== prevKey || gap > 2*3600*1000) groups.push([]);
|
||
groups[groups.length-1].push(n);
|
||
prevKey = key; prevT = t;
|
||
});
|
||
return groups;
|
||
}
|
||
// 浏览弹窗选回来的文件:视频按场次归组,弹幕直接跳过(视频自己会找同名弹幕)
|
||
function addMergeVideos(names){
|
||
if(!names || !names.length) return;
|
||
var vids = names.filter(function(n){ return /\.(mp4|m4v|flv|ts|mkv|mov|webm)$/i.test(n); });
|
||
if(!vids.length){ showMsg(false, "选的是弹幕文件,视频请另外选"); return; }
|
||
var added = 0;
|
||
groupVideos(vids).forEach(function(g){
|
||
if(mergeJobsHave(g[0])) return;
|
||
mrgJobs.push({id: ++mrgSeq, videos: g, danmu: "", out: "", offset: "", br: "", fps: "", fs: "", enc: ""});
|
||
added++;
|
||
});
|
||
renderMergeJobs();
|
||
if(added) autoMatchAllDanmu();
|
||
}
|
||
// 这个视频是不是已经在某个任务里了(重复点选不该加出第二个一样的任务)
|
||
function mergeJobsHave(name){
|
||
for(var i = 0; i < mrgJobs.length; i++){
|
||
if(mrgJobs[i].videos.indexOf(name) >= 0) return true;
|
||
}
|
||
return false;
|
||
}
|
||
// 从文件列表里勾选
|
||
function pickVideos(){
|
||
var names = [];
|
||
Array.prototype.forEach.call(document.getElementById("mrgVideoPick").options, function(o){ if(o.selected) names.push(o.value); });
|
||
names.sort(); // 按文件名里的录制时间排,点选顺序无所谓
|
||
addMergeVideos(names);
|
||
}
|
||
function removeMergeJob(i){
|
||
if(mrgBusy) return;
|
||
mrgJobs.splice(i, 1);
|
||
renderMergeJobs();
|
||
}
|
||
// 一个任务里有好几段时拆成一人一个任务(本来就是分开的两场却归到一组时用)
|
||
function splitMergeJob(i){
|
||
if(mrgBusy) return;
|
||
var job = mrgJobs[i];
|
||
if(!job || job.videos.length < 2) return;
|
||
var parts = job.videos.map(function(v){ return {id: ++mrgSeq, videos: [v], danmu: "", out: job.out,
|
||
offset: job.offset, br: job.br, fps: job.fps, fs: job.fs, enc: job.enc}; });
|
||
mrgJobs.splice.apply(mrgJobs, [i, 1].concat(parts));
|
||
renderMergeJobs();
|
||
autoMatchAllDanmu();
|
||
}
|
||
function pickDanmuFor(i){
|
||
openPick(null, "file", false, function(v){
|
||
if(mrgJobs[i]) mrgJobs[i].danmu = v[0] || "";
|
||
renderMergeJobs();
|
||
});
|
||
}
|
||
// 每个还没填弹幕的任务都按视频名找一次同名的弹幕文件
|
||
async function autoMatchAllDanmu(){
|
||
for(var i = 0; i < mrgJobs.length; i++){
|
||
var job = mrgJobs[i];
|
||
if(job.danmu || !job.videos.length) continue;
|
||
var r = null, d = null;
|
||
try{ r = await api("/api/merge/danmu?video=" + encodeURIComponent(job.videos[0])); }catch(e){ continue; }
|
||
if(!r || !r.ok) continue;
|
||
try{ d = await r.json(); }catch(e){ continue; }
|
||
if(d && d.danmu) job.danmu = d.danmu;
|
||
}
|
||
renderMergeJobs();
|
||
}
|
||
function renderMergeJobs(){
|
||
var box = document.getElementById("mrgJobs");
|
||
document.getElementById("mrgEmpty").style.display = mrgJobs.length ? "none" : "";
|
||
box.innerHTML = mrgJobs.map(function(job, i){
|
||
return '<div class="trow" data-i="' + i + '">'
|
||
+ '<div class="th"><span class="tn">第 ' + (i+1) + ' 个' + (job.videos.length > 1 ? '(' + job.videos.length + ' 段拼接)' : '') + '</span>'
|
||
+ (job.videos.length > 1 ? '<button class="mini" onclick="splitMergeJob(' + i + ')">拆成单独任务</button>' : '')
|
||
+ '<button class="mini" onclick="removeMergeJob(' + i + ')">移除</button></div>'
|
||
+ '<div class="tvid">' + job.videos.map(function(v){ return esc(v); }).join("<br>") + '</div>'
|
||
+ '<div class="tg">'
|
||
+ '<div class="tf"><label>弹幕文件</label><div class="pathf">'
|
||
+ '<input type="text" class="mj-danmu" list="mrgDanmuList" autocomplete="off" spellcheck="false" value="' + esc(job.danmu) + '" placeholder="留空自动找同名的">'
|
||
+ '<button class="mini" onclick="pickDanmuFor(' + i + ')">…</button></div></div>'
|
||
+ '<div class="tf"><label>输出文件名</label><input type="text" class="mj-out" autocomplete="off" value="' + esc(job.out) + '" placeholder="留空自动"></div>'
|
||
+ '<div class="tf"><label>偏移(秒)</label><input type="text" class="mj-offset" inputmode="decimal" autocomplete="off" value="' + esc(job.offset) + '" placeholder="留空自动"></div>'
|
||
+ '<div class="tf"><label>码率(Kbps)</label><input type="text" class="mj-br" inputmode="numeric" autocomplete="off" value="' + esc(job.br) + '" placeholder="跟随设置"></div>'
|
||
+ '<div class="tf"><label>输出帧率</label><input type="text" class="mj-fps" inputmode="numeric" autocomplete="off" value="' + esc(job.fps) + '" placeholder="跟随设置"></div>'
|
||
+ '<div class="tf"><label>弹幕字号(像素)</label><input type="text" class="mj-fs" inputmode="numeric" autocomplete="off" value="' + esc(job.fs) + '" placeholder="跟随设置"></div>'
|
||
+ '<div class="tf"><label>编码器</label>' + encSelectHtml("mj-enc", job.enc) + '</div>'
|
||
+ '</div></div>';
|
||
}).join("");
|
||
var rows = document.querySelectorAll("#mrgJobs .trow");
|
||
for(var k = 0; k < rows.length; k++) bindMergeRow(rows[k]);
|
||
}
|
||
// 行里的输入直接写回对应的任务对象,重画时不会丢
|
||
function bindMergeRow(row){
|
||
var job = mrgJobs[parseInt(row.dataset.i, 10)];
|
||
if(!job) return;
|
||
bindRowInput(row, ".mj-danmu", function(v){ job.danmu = v; });
|
||
bindRowInput(row, ".mj-out", function(v){ job.out = v; });
|
||
bindRowInput(row, ".mj-offset", function(v){ job.offset = v; });
|
||
bindRowInput(row, ".mj-br", function(v){ job.br = v; });
|
||
bindRowInput(row, ".mj-fps", function(v){ job.fps = v; });
|
||
bindRowInput(row, ".mj-fs", function(v){ job.fs = v; });
|
||
var sel = row.querySelector(".mj-enc");
|
||
if(sel) sel.addEventListener("change", function(){ job.enc = sel.value; });
|
||
}
|
||
function bindRowInput(row, css, set){
|
||
var el = row.querySelector(css);
|
||
if(!el) return;
|
||
el.addEventListener("input", function(){ set(el.value); });
|
||
el.addEventListener("change", function(){ set(el.value); });
|
||
}
|
||
// 拼请求体。留空的项就不带,服务端按全局设置走
|
||
function mrgPayload(){
|
||
var jobs = [];
|
||
for(var i = 0; i < mrgJobs.length; i++){
|
||
var job = mrgJobs[i];
|
||
if(!job.videos.length){ showMsg(false, "第 " + (i+1) + " 个任务没有视频"); return null; }
|
||
var one = {videos: job.videos, danmu: job.danmu.trim(), outName: job.out.trim()};
|
||
var off = job.offset.trim();
|
||
if(off !== ""){
|
||
var v = parseFloat(off);
|
||
if(isNaN(v)){ showMsg(false, "第 " + (i+1) + " 个任务的偏移必须是数字"); return null; }
|
||
one.offset = v;
|
||
}
|
||
var opts = {}, nums = {bitrate: job.br, fps: job.fps, fontSize: job.fs};
|
||
for(var key in nums){
|
||
var s = (nums[key] || "").trim();
|
||
if(s === "") continue;
|
||
var n = parseInt(s, 10);
|
||
if(isNaN(n) || n < 0){ showMsg(false, "第 " + (i+1) + " 个任务的参数必须是 0 或正整数"); return null; }
|
||
opts[key] = n;
|
||
}
|
||
if(job.enc) opts.encoder = job.enc;
|
||
one.options = opts;
|
||
jobs.push(one);
|
||
}
|
||
if(!jobs.length){ showMsg(false, "请先选择要合并的视频"); return null; }
|
||
return {jobs: jobs};
|
||
}
|
||
// 用文件列表里的文件填下拉框和输入建议
|
||
function fillMergePickers(){
|
||
var vids = [], dms = [];
|
||
fileList.forEach(function(f){
|
||
if(/\.(mp4|m4v|flv|ts|mkv|mov|webm)$/i.test(f.name)) vids.push(f);
|
||
else if(/\.(jsonl|ass)$/i.test(f.name)) dms.push(f);
|
||
});
|
||
// 文件名以录制时间开头,升序就是录制顺序
|
||
vids.sort(function(a,b){ return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); });
|
||
dms.sort(function(a,b){ return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); });
|
||
var sel = document.getElementById("mrgVideoPick");
|
||
var keep = {};
|
||
Array.prototype.forEach.call(sel.options, function(o){ if(o.selected) keep[o.value] = 1; });
|
||
sel.innerHTML = vids.map(function(f){
|
||
return '<option value="' + esc(f.name) + '">' + esc(f.name) + ' ' + fmtSize(f.size) + '</option>';
|
||
}).join("");
|
||
// 刷新列表时不要把已经选好的分段弄丢
|
||
Array.prototype.forEach.call(sel.options, function(o){ if(keep[o.value]) o.selected = true; });
|
||
document.getElementById("mrgDanmuList").innerHTML = dms.map(function(f){
|
||
return '<option value="' + esc(f.name) + '"></option>';
|
||
}).join("");
|
||
}
|
||
function setMergeState(st){
|
||
st = st || {};
|
||
var go = document.getElementById("mrgGo");
|
||
go.disabled = mrgBusy;
|
||
go.textContent = mrgBusy ? "合并中…" : "开始合并";
|
||
document.getElementById("mrgCheck").disabled = mrgBusy;
|
||
var txt = "";
|
||
if(mrgBusy){
|
||
if(st.mergeQueueTotal > 1){
|
||
var n = Math.min((st.mergeQueueDone || 0) + 1, st.mergeQueueTotal);
|
||
txt = "正在合并第 " + n + "/" + st.mergeQueueTotal + " 个:" + (st.mergeOutName || "");
|
||
}else{
|
||
txt = "正在合并,进度见「监控」页";
|
||
}
|
||
}
|
||
document.getElementById("mrgState").textContent = txt;
|
||
}
|
||
// 把检查结果按任务分段显示
|
||
function mergeInfoText(infos){
|
||
var lines = [];
|
||
infos.forEach(function(d, i){
|
||
if(infos.length > 1) lines.push("── 第 " + (i+1) + " 个 ──");
|
||
(d.videos||[]).forEach(function(v){
|
||
lines.push("视频 " + v.name + " " + fmtSize(v.size) + " 时长 " + v.duration.toFixed(1) + " 秒" + (v.inUse ? "(正在写入)" : ""));
|
||
});
|
||
lines.push("视频总时长 " + d.duration.toFixed(1) + " 秒");
|
||
lines.push("弹幕 " + d.danmuName + " " + d.danmuCount + " 条 时间 " + d.danmuFirst.toFixed(1) + " ~ " + d.danmuLast.toFixed(1) + " 秒(" + d.source + ")");
|
||
lines.push("偏移 " + d.offset.toFixed(3) + " 秒(" + d.offsetNote + ")");
|
||
lines.push("输出 " + d.outName);
|
||
(d.warnings||[]).forEach(function(w){ lines.push("注意:" + w); });
|
||
});
|
||
return lines.join("\n");
|
||
}
|
||
async function checkMerge(){
|
||
await autoMatchAllDanmu();
|
||
var body = mrgPayload();
|
||
if(!body) return;
|
||
var b = document.getElementById("mrgCheck");
|
||
b.disabled = true;
|
||
var r = null;
|
||
try{ r = await api("/api/merge/info", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)}); }catch(e){}
|
||
b.disabled = mrgBusy;
|
||
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||
if(!r || !r.ok){ showMsg(false, r ? await r.text() : "连接失败"); return; }
|
||
var d = await r.json();
|
||
var infos = d && d.jobs ? d.jobs : [d];
|
||
document.getElementById("mrgInfo").textContent = mergeInfoText(infos);
|
||
}
|
||
async function startMerge(){
|
||
await autoMatchAllDanmu();
|
||
var body = mrgPayload();
|
||
if(!body) return;
|
||
var n = body.jobs.length;
|
||
if(!confirm(n > 1
|
||
? ("开始合并这 " + n + " 个任务?会按列表顺序一个一个来,进度见「监控」页。")
|
||
: "开始合并?合成期间可以在「监控」页的运行日志里看进度。")) return;
|
||
var b = document.getElementById("mrgGo");
|
||
b.disabled = true;
|
||
var r = null;
|
||
try{ r = await api("/api/merge", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)}); }catch(e){}
|
||
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); b.disabled = false; return; }
|
||
if(!r || !r.ok){
|
||
showMsg(false, r ? await r.text() : "连接失败");
|
||
b.disabled = false;
|
||
return;
|
||
}
|
||
var d = await r.json();
|
||
mrgBusy = true;
|
||
setMergeState(lastSt);
|
||
showMsg(true, n > 1 ? ("已开始合并 " + n + " 个任务") : ("已开始合并:" + (d.outName || "")));
|
||
}
|
||
// 合并状态跟着 /api/status 一起刷新,只在「进行中 → 结束」这一刻提示一次
|
||
function syncMergeState(st){
|
||
var busy = !!st.mergeBusy;
|
||
var was = mrgBusy;
|
||
mrgBusy = busy;
|
||
setMergeState(st);
|
||
if(busy || !was) return;
|
||
loadFiles();
|
||
var rs = st.mergeResults || [];
|
||
if(rs.length > 1){
|
||
var ok = 0;
|
||
rs.forEach(function(r){ if(!r.err) ok++; });
|
||
document.getElementById("mrgInfo").textContent = "合并结束:共 " + rs.length + " 个,成功 " + ok + " 个\n"
|
||
+ rs.map(function(r, i){ return "第 " + (i+1) + " 个 " + r.outName + (r.err ? " 失败:" + r.err : " 完成"); }).join("\n");
|
||
}
|
||
if(st.mergeErr) showMsg(false, "合并失败:" + st.mergeErr);
|
||
else showMsg(true, rs.length > 1 ? ("手动合并全部结束,成功 " + rs.length + " 个") : "手动合并结束,结果见「监控」页的运行日志");
|
||
}
|
||
|
||
// ========== 转码 ==========
|
||
var tcJobs = []; // {dir,name,size,dur,w,h,fps,vkbps,akbps,audio,codec,out,opts,est}
|
||
var tcVideos = []; // 文件列表里的视频,下拉框按它在数组里的下标选
|
||
var tcEncoders = []; // 本机可用的编码器
|
||
var tcBusy = false;
|
||
var tcEstTimer = null;
|
||
var tcEstSeq = 0; // 估算请求的序号,回包时对不上就说明有更新的一次在跑,这次作废
|
||
|
||
function fillTranscodePick(){
|
||
tcVideos = fileList.filter(function(f){ return /\.(mp4|m4v|flv|ts|mkv|mov|webm)$/i.test(f.name); });
|
||
tcVideos.sort(function(a,b){ return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); });
|
||
var sel = document.getElementById("tcFilePick");
|
||
// 刷新列表时不要把已经勾好的弄丢(和合并页那个下拉一样)
|
||
var keep = {};
|
||
Array.prototype.forEach.call(sel.options, function(o){ if(o.selected) keep[o.value] = 1; });
|
||
sel.innerHTML = tcVideos.map(function(f, i){
|
||
return '<option value="' + i + '">' + esc(f.name) + ' ' + fmtSize(f.size) + '</option>';
|
||
}).join("");
|
||
Array.prototype.forEach.call(sel.options, function(o){ if(keep[o.value]) o.selected = true; });
|
||
}
|
||
// 新加一个文件时给的默认参数:优先硬件 HEVC,码率按源片的六成,音轨直接拷
|
||
function defaultTranscodeOpts(){
|
||
return {encoder: "", mode: "percent", bitrate: 0, quality: 23, percent: 60,
|
||
preset: "medium", fps: 0, scale: 0, audio: "copy"};
|
||
}
|
||
function defaultTranscodeOut(p){
|
||
return p.name.replace(/\.[^.]+$/, "") + "_转码.mp4";
|
||
}
|
||
async function addTranscodeFiles(){
|
||
var sel = document.getElementById("tcFilePick");
|
||
var picks = [];
|
||
Array.prototype.forEach.call(sel.options, function(o){
|
||
if(o.selected){
|
||
var f = tcVideos[parseInt(o.value, 10)];
|
||
if(f && !tcJobs.some(function(j){ return j.dir === f.dir && j.name === f.name; })) picks.push(f);
|
||
}
|
||
});
|
||
if(!picks.length){ showMsg(false, tcJobs.length ? "选中的文件已经在列表里了" : "请先选择要转码的文件"); return; }
|
||
var r = null;
|
||
try{
|
||
r = await api("/api/transcode/probe", {method:"POST", headers:{"Content-Type":"application/json"},
|
||
body: JSON.stringify({files: picks.map(function(f){ return {dir: f.dir, name: f.name}; })})});
|
||
}catch(e){}
|
||
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||
if(!r || !r.ok){ showMsg(false, r ? await r.text() : "连接失败"); return; }
|
||
var d = null;
|
||
try{ d = await r.json(); }catch(e){ showMsg(false, "读文件信息失败"); return; }
|
||
if(d && d.encoders && d.encoders.length) tcEncoders = d.encoders;
|
||
var skipped = [];
|
||
(d.files || []).forEach(function(p){
|
||
// 只有音轨(或只有封面图)的文件排进去也是白排,ffmpeg 会直接报没视频流
|
||
if(!p.hasVideo){ skipped.push(p.name); return; }
|
||
tcJobs.push({dir: p.dir, name: p.name, size: p.size, dur: p.duration, w: p.width, h: p.height,
|
||
fps: p.fps, vkbps: p.videoKbps, akbps: p.audioKbps, audio: !!p.hasAudio, codec: p.videoCodec,
|
||
out: defaultTranscodeOut(p), opts: defaultTranscodeOpts(), est: null});
|
||
});
|
||
renderTranscodeRows();
|
||
estimateTranscode();
|
||
if(skipped.length) showMsg(false, "这些文件里没有视频轨,已跳过:" + skipped.join("、"));
|
||
}
|
||
function clearTranscodeFiles(){
|
||
if(tcBusy) return;
|
||
tcJobs = [];
|
||
renderTranscodeRows();
|
||
updateTranscodeTotal();
|
||
}
|
||
function removeTranscodeFile(i){
|
||
if(tcBusy) return;
|
||
tcJobs.splice(i, 1);
|
||
renderTranscodeRows();
|
||
updateTranscodeTotal();
|
||
}
|
||
// 第一个文件调好之后,其余的照抄一份(多数情况整批用同一套参数)
|
||
function applyTranscodeToAll(){
|
||
if(!tcJobs.length) return;
|
||
var o = tcJobs[0].opts;
|
||
for(var i = 1; i < tcJobs.length; i++) tcJobs[i].opts = JSON.parse(JSON.stringify(o));
|
||
renderTranscodeRows();
|
||
estimateTranscode();
|
||
}
|
||
function encSelectHtml(cls, cur){
|
||
var list = tcEncoders.length ? tcEncoders : ["hevc_qsv", "h264_qsv", "libx264"];
|
||
return '<select class="' + cls + '"><option value="">自动(按本机可用性挑)</option>'
|
||
+ list.map(function(e){
|
||
var label = e === "hevc_qsv" ? "HEVC 硬件(hevc_qsv)"
|
||
: (e === "h264_qsv" ? "H.264 硬件(h264_qsv)" : (e === "libx264" ? "H.264 软件(libx264)" : e));
|
||
return '<option value="' + e + '"' + (cur === e ? " selected" : "") + '>' + label + '</option>';
|
||
}).join("") + '</select>';
|
||
}
|
||
// 码率控制模式决定那个数值框填的是什么
|
||
function tcNumField(o){
|
||
if(o.mode === "bitrate") return {label: "目标码率(Kbps)", val: o.bitrate || "", ph: "例如 2000"};
|
||
if(o.mode === "quality") return {label: "质量值(0-51)", val: o.quality || "", ph: "越小越清晰,默认 23"};
|
||
if(o.mode === "percent") return {label: "源片码率的比例(%)", val: o.percent || "", ph: "例如 60"};
|
||
return {label: "数值(当前模式用不到)", val: "", ph: "跟随源片"};
|
||
}
|
||
function renderTranscodeRows(){
|
||
document.getElementById("tcEmpty").style.display = tcJobs.length ? "none" : "";
|
||
document.getElementById("tcCount").textContent = tcJobs.length ? (tcJobs.length + " 个文件") : "";
|
||
document.getElementById("tcRows").innerHTML = tcJobs.map(function(job, i){
|
||
var o = job.opts;
|
||
var num = tcNumField(o);
|
||
var meta = [job.w && job.h ? (job.w + "x" + job.h) : "", job.fps ? (Math.round(job.fps*10)/10 + " fps") : "",
|
||
job.vkbps ? (job.vkbps + " kbps") : "", job.codec ? ("源 " + job.codec) : "",
|
||
job.dur ? fmtDur(job.dur) : "", fmtSize(job.size), job.audio ? "" : "无音轨"]
|
||
.filter(function(x){ return x; }).join(" ");
|
||
var scales = [0, 2160, 1440, 1080, 720, 480, 360].filter(function(h){
|
||
return h === 0 || !job.h || h < job.h;
|
||
});
|
||
return '<div class="trow" data-i="' + i + '">'
|
||
+ '<div class="th"><span class="tn">' + esc(job.name) + '</span>'
|
||
+ '<span class="tm">' + esc(meta) + '</span>'
|
||
+ '<button class="mini" onclick="removeTranscodeFile(' + i + ')">移除</button></div>'
|
||
+ '<div class="tg">'
|
||
+ '<div class="tf"><label>输出文件名</label><input type="text" class="tc-out" autocomplete="off" value="' + esc(job.out) + '"></div>'
|
||
+ '<div class="tf"><label>编码器</label>' + encSelectHtml("tc-enc", o.encoder) + '</div>'
|
||
+ '<div class="tf"><label>码率控制</label><select class="tc-mode">'
|
||
+ '<option value="percent"' + (o.mode === "percent" ? " selected" : "") + '>源片码率的比例</option>'
|
||
+ '<option value="bitrate"' + (o.mode === "bitrate" ? " selected" : "") + '>指定目标码率</option>'
|
||
+ '<option value="quality"' + (o.mode === "quality" ? " selected" : "") + '>质量模式(CRF / ICQ)</option>'
|
||
+ '<option value="source"' + (o.mode === "source" ? " selected" : "") + '>跟随源片码率</option>'
|
||
+ '</select></div>'
|
||
+ '<div class="tf"><label>' + num.label + '</label><input type="text" class="tc-num tn-num" inputmode="numeric" autocomplete="off" value="' + esc(String(num.val)) + '" placeholder="' + num.ph + '"' + (o.mode === "source" ? " disabled" : "") + '></div>'
|
||
+ '<div class="tf"><label>编码速度</label><select class="tc-preset">'
|
||
+ ["veryfast","faster","fast","medium","slow","slower"].map(function(p){
|
||
return '<option value="' + p + '"' + (o.preset === p ? " selected" : "") + '>' + p + '</option>';
|
||
}).join("")
|
||
+ '</select></div>'
|
||
+ '<div class="tf"><label>输出帧率(0=跟随)</label><input type="text" class="tc-fps tn-num" inputmode="numeric" autocomplete="off" value="' + esc(String(o.fps || 0)) + '"></div>'
|
||
+ '<div class="tf"><label>分辨率(按高度)</label><select class="tc-scale">'
|
||
+ scales.map(function(h){
|
||
return '<option value="' + h + '"' + (o.scale === h ? " selected" : "") + '>' + (h ? (h + "p") : "原样") + '</option>';
|
||
}).join("")
|
||
+ '</select></div>'
|
||
+ '<div class="tf"><label>音频</label><select class="tc-audio">'
|
||
+ '<option value="copy"' + (o.audio === "copy" ? " selected" : "") + '>直接拷贝</option>'
|
||
+ '<option value="aac192"' + (o.audio === "aac192" ? " selected" : "") + '>重编码 192k</option>'
|
||
+ '<option value="aac128"' + (o.audio === "aac128" ? " selected" : "") + '>重编码 128k</option>'
|
||
+ '<option value="none"' + (o.audio === "none" ? " selected" : "") + '>去掉音轨</option>'
|
||
+ '</select></div>'
|
||
+ '</div>'
|
||
+ '<div class="tr"><span>预估产物 <b class="tc-est">—</b></span>'
|
||
+ '<span class="tc-rate"></span><span class="tc-note"></span><span class="tc-badge"></span></div>'
|
||
+ '</div>';
|
||
}).join("");
|
||
var rows = document.querySelectorAll("#tcRows .trow");
|
||
for(var k = 0; k < rows.length; k++) bindTcRow(rows[k]);
|
||
paintTranscodeEst();
|
||
}
|
||
// 行里的改动写回任务对象;下拉框一改就要重画(数值框的含义会跟着模式变)
|
||
function bindTcRow(row){
|
||
var job = tcJobs[parseInt(row.dataset.i, 10)];
|
||
if(!job) return;
|
||
bindRowInput(row, ".tc-out", function(v){ job.out = v; });
|
||
bindRowInput(row, ".tc-num", function(v){ readTcNum(job, v); });
|
||
bindRowInput(row, ".tc-fps", function(v){ var n = parseInt(v, 10); job.opts.fps = isNaN(n) || n < 0 ? 0 : n; });
|
||
var sel = row.querySelector(".tc-mode");
|
||
if(sel) sel.addEventListener("change", function(){ job.opts.mode = sel.value; renderTranscodeRows(); scheduleEstimate(); });
|
||
var enc = row.querySelector(".tc-enc");
|
||
if(enc) enc.addEventListener("change", function(){ job.opts.encoder = enc.value; scheduleEstimate(); });
|
||
var pre = row.querySelector(".tc-preset");
|
||
if(pre) pre.addEventListener("change", function(){ job.opts.preset = pre.value; scheduleEstimate(); });
|
||
var sc = row.querySelector(".tc-scale");
|
||
if(sc) sc.addEventListener("change", function(){ job.opts.scale = parseInt(sc.value, 10) || 0; scheduleEstimate(); });
|
||
var au = row.querySelector(".tc-audio");
|
||
if(au) au.addEventListener("change", function(){ job.opts.audio = au.value; scheduleEstimate(); });
|
||
}
|
||
function readTcNum(job, v){
|
||
var n = parseInt(v, 10);
|
||
if(isNaN(n) || n < 0) n = 0;
|
||
if(job.opts.mode === "bitrate") job.opts.bitrate = n;
|
||
else if(job.opts.mode === "quality") job.opts.quality = n;
|
||
else if(job.opts.mode === "percent") job.opts.percent = n;
|
||
scheduleEstimate();
|
||
}
|
||
function scheduleEstimate(){
|
||
if(tcEstTimer) clearTimeout(tcEstTimer);
|
||
tcEstTimer = setTimeout(estimateTranscode, 350);
|
||
}
|
||
// 体积估算是服务端算的(公式只有一份,界面和落地的预估不会打架),输入停一下才发
|
||
async function estimateTranscode(){
|
||
if(!tcJobs.length){ updateTranscodeTotal(); return; }
|
||
var seq = ++tcEstSeq;
|
||
var body = {jobs: tcJobs.map(function(j){ return {dir: j.dir, name: j.name, options: j.opts}; })};
|
||
var r = null;
|
||
try{
|
||
r = await api("/api/transcode/estimate", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)});
|
||
}catch(e){ return; }
|
||
if(seq !== tcEstSeq) return; // 已经有更新的一次在跑,这次的结果别覆盖它
|
||
// 参数不合法时服务端会 400。这里必须把原因说出来,否则填错了框只会看到
|
||
// 预估体积一动不动,还以为是自己没改成功
|
||
if(!r || !r.ok){
|
||
document.getElementById("tcTotal").textContent = "估算失败:" + (r ? await r.text() : "连接失败");
|
||
return;
|
||
}
|
||
var d = null;
|
||
try{ d = await r.json(); }catch(e){ return; }
|
||
if(seq !== tcEstSeq) return;
|
||
var ests = (d && d.jobs) || [];
|
||
for(var i = 0; i < tcJobs.length && i < ests.length; i++) tcJobs[i].est = ests[i];
|
||
paintTranscodeEst();
|
||
updateTranscodeTotal();
|
||
}
|
||
function paintTranscodeEst(){
|
||
var rows = document.querySelectorAll("#tcRows .trow");
|
||
for(var k = 0; k < rows.length; k++){
|
||
var job = tcJobs[parseInt(rows[k].dataset.i, 10)];
|
||
if(!job) continue;
|
||
var est = rows[k].querySelector(".tc-est"), rate = rows[k].querySelector(".tc-rate"), note = rows[k].querySelector(".tc-note");
|
||
if(!est) continue;
|
||
if(!job.est || !job.est.bytes){ est.textContent = "—"; continue; }
|
||
var pct = job.size > 0 ? Math.round(job.est.bytes / job.size * 100) : 0;
|
||
est.textContent = fmtSize(job.est.bytes) + "(源片的 " + pct + "%)";
|
||
rate.textContent = "视频 " + job.est.videoKbps + " kbps + 音频 " + job.est.audioKbps + " kbps";
|
||
note.textContent = job.est.exact ? "" : ("· " + (job.est.note || "粗估"));
|
||
}
|
||
}
|
||
function updateTranscodeTotal(){
|
||
var el = document.getElementById("tcTotal");
|
||
var src = 0, out = 0, got = 0;
|
||
tcJobs.forEach(function(j){ src += j.size; if(j.est && j.est.bytes){ out += j.est.bytes; got++; } });
|
||
if(!tcJobs.length){ el.textContent = ""; return; }
|
||
if(!got){ el.textContent = "共 " + fmtSize(src); return; }
|
||
var pct = src > 0 ? Math.round(out / src * 100) : 0;
|
||
el.textContent = "合计 " + fmtSize(src) + " → " + fmtSize(out) + "(" + pct + "%)";
|
||
}
|
||
function setTranscodeState(st){
|
||
st = st || {};
|
||
var go = document.getElementById("tcGo");
|
||
go.disabled = tcBusy;
|
||
go.textContent = tcBusy ? "转码中…" : "开始转码";
|
||
var txt = "";
|
||
if(tcBusy && st.transcodeTotal > 1){
|
||
txt = "正在转第 " + Math.min((st.transcodeDone || 0) + 1, st.transcodeTotal) + "/" + st.transcodeTotal + " 个:" + (st.transcodeCurrent || "");
|
||
}else if(tcBusy){
|
||
txt = "正在转码,进度见「监控」页";
|
||
}
|
||
document.getElementById("tcState").textContent = txt;
|
||
}
|
||
function syncTranscodeState(st){
|
||
var busy = !!st.transcodeBusy;
|
||
var was = tcBusy;
|
||
tcBusy = busy;
|
||
setTranscodeState(st);
|
||
// 跑完给每行标一下结果
|
||
var rs = st.transcodeResults || [];
|
||
var rows = document.querySelectorAll("#tcRows .trow");
|
||
for(var k = 0; k < rows.length; k++){
|
||
var badge = rows[k].querySelector(".tc-badge");
|
||
if(!badge) continue;
|
||
var res = rs[k];
|
||
if(!busy && res){
|
||
badge.innerHTML = res.err
|
||
? '<span class="tbadge bad">失败</span> ' + esc(res.err)
|
||
: '<span class="tbadge ok">完成</span> ' + fmtSize(res.bytes || 0);
|
||
rows[k].classList.toggle("done", !res.err);
|
||
}else{
|
||
if(busy && k === (st.transcodeDone || 0)) badge.innerHTML = '<span class="tbadge">进行中</span>';
|
||
else badge.innerHTML = "";
|
||
rows[k].classList.remove("done");
|
||
}
|
||
}
|
||
if(!busy && was && rs.length){
|
||
var ok = 0;
|
||
rs.forEach(function(r){ if(!r.err) ok++; });
|
||
showMsg(true, "转码结束:共 " + rs.length + " 个,成功 " + ok + " 个");
|
||
loadFiles();
|
||
}
|
||
}
|
||
async function startTranscode(){
|
||
if(!tcJobs.length){ showMsg(false, "请先选择要转码的文件"); return; }
|
||
var n = tcJobs.length;
|
||
if(!confirm(n > 1 ? ("开始转码这 " + n + " 个文件?会按列表顺序一个一个来。") : "开始转码?")) return;
|
||
var body = {outDir: document.getElementById("tcOutDir").value.trim(),
|
||
jobs: tcJobs.map(function(j){ return {dir: j.dir, name: j.name, outName: j.out, options: j.opts}; })};
|
||
var b = document.getElementById("tcGo");
|
||
b.disabled = true;
|
||
var r = null;
|
||
try{ r = await api("/api/transcode", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)}); }catch(e){}
|
||
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); b.disabled = false; return; }
|
||
if(!r || !r.ok){ showMsg(false, r ? await r.text() : "连接失败"); b.disabled = false; return; }
|
||
tcBusy = true;
|
||
setTranscodeState(lastSt);
|
||
showMsg(true, n > 1 ? ("已开始转码 " + n + " 个文件") : "已开始转码");
|
||
}
|
||
async function testNotify(b, channel){
|
||
if(b) b.disabled = true;
|
||
var body = {channel: channel || "qq"};
|
||
if(body.channel === "meow"){
|
||
body.nickname = document.getElementById("meowNickname").value.trim();
|
||
body.msgType = document.getElementById("meowMsgType").value;
|
||
body.imgUrl = document.getElementById("meowImgUrl").value.trim();
|
||
body.htmlHeight = parseInt(document.getElementById("meowHtmlHeight").value, 10) || 0;
|
||
}else{
|
||
body.url = document.getElementById("notifyUrl").value.trim();
|
||
body.token = document.getElementById("notifyToken").value.trim();
|
||
body.group = document.getElementById("notifyGroup").value.trim();
|
||
}
|
||
var r = null, d = null;
|
||
try{
|
||
r = await api("/api/notify/test", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)});
|
||
if(r && r.ok) d = await r.json();
|
||
}catch(e){}
|
||
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); if(b) b.disabled = false; return; }
|
||
if(r && r.ok) showMsg(true, "测试成功:" + ((d && d.detail) ? d.detail : "推送已发出"));
|
||
else showMsg(false, "测试失败:" + (r ? await r.text() : "连接失败"));
|
||
if(b) b.disabled = false;
|
||
}
|
||
async function addUid(){
|
||
var uid = parseInt(document.getElementById("newuid").value.trim(), 10);
|
||
if(!(uid > 0)){ showMsg(false, "uid 不合法"); return; }
|
||
if(await post("/api/streamer", {uid: uid, danmu: true, record: true})) document.getElementById("newuid").value = "";
|
||
}
|
||
async function ctl(uid, action){
|
||
var name = action === "stop" ? "停止" : "重新开始";
|
||
if(action === "stop" && !confirm("确定停止 uid " + uid + " 当前的录制?停止后需点重新开始才会继续录。")) return;
|
||
await post("/api/streamer/control", {uid: uid, target: "all", action: action});
|
||
showMsg(true, "已" + name + "录制");
|
||
}
|
||
async function delUid(uid){
|
||
if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return;
|
||
post("/api/streamer/remove", {uid: uid});
|
||
}
|
||
// 选到「自定义」时当场把码率框建出来:列表在「正在文本框里打字」时不会重建,
|
||
// 光等服务端回来是看不到输入框的,用户会以为没地方填。顺带把焦点放进去,直接就能敲数字
|
||
function showBrNum(row, sel, focus){
|
||
var el = row.querySelector("input.brnum");
|
||
if(el){
|
||
if(focus){ el.focus(); el.select(); }
|
||
return el;
|
||
}
|
||
var uid = parseInt(sel.dataset.uid, 10);
|
||
var cur = (lastSt && lastSt.streamers ? lastSt.streamers : []).filter(function(s){ return s.uid === uid; })[0];
|
||
el = document.createElement("input");
|
||
el.type = "text";
|
||
el.className = "brnum";
|
||
el.dataset.uid = String(uid);
|
||
el.inputMode = "numeric";
|
||
el.placeholder = "Kbps";
|
||
el.title = "自定义:取不超过这个码率的最高档";
|
||
// 初始值:填过自定义就用上次那个数(切走再切回来不该被改掉),没填过就用现在实际在用的那一档,
|
||
// 免得切过去的一瞬间变成最低档
|
||
var v = cur ? (cur.bitrate || 0) : 0;
|
||
if(!(v > 0) && cur) v = cur.qualityBitrate || 0;
|
||
el.value = v;
|
||
sel.parentNode.insertBefore(el, sel.nextSibling);
|
||
if(focus){ el.focus(); el.select(); }
|
||
return el;
|
||
}
|
||
function hideBrNum(row){
|
||
var el = row.querySelector("input.brnum");
|
||
if(el && el.parentNode) el.parentNode.removeChild(el);
|
||
}
|
||
// 读一行里选的档位:pref 是下拉的值;自定义时把输入框里的码率一起带上,
|
||
// 输入框不在(刚切到自定义、或不是自定义)就不带 bitrate,服务端会保留原来填的值
|
||
function rowQuality(row){
|
||
var sel = row.querySelector("select.brsel");
|
||
var pref = sel ? sel.value : "second";
|
||
var num = row.querySelector("input.brnum");
|
||
var out = {qualityPref: pref};
|
||
if(pref === "custom"){
|
||
var v;
|
||
if(num){
|
||
v = parseInt(num.value.trim(), 10);
|
||
if(isNaN(v) || v < 0) return null;
|
||
}else{
|
||
// 兜底:填过自定义就用上次那个数,没填过就用现在实际在用的那一档
|
||
var uid0 = parseInt(sel.dataset.uid, 10);
|
||
var cur0 = (lastSt && lastSt.streamers ? lastSt.streamers : []).filter(function(s){ return s.uid === uid0; })[0];
|
||
v = cur0 ? (cur0.bitrate || 0) : 0;
|
||
if(!(v > 0) && cur0) v = cur0.qualityBitrate || 0;
|
||
}
|
||
out.bitrate = v;
|
||
}
|
||
return out;
|
||
}
|
||
document.addEventListener("change", async function(e){
|
||
var t = e.target;
|
||
if(t.matches('input[type=checkbox][data-uid]')){
|
||
var uid = parseInt(t.dataset.uid, 10);
|
||
var row = t.closest(".row");
|
||
var q = rowQuality(row);
|
||
if(!q){ showMsg(false, "自定义档位的码率必须是大于等于 0 的数字"); return; }
|
||
q.uid = uid;
|
||
q.danmu = row.querySelector('input[data-k=danmu]').checked;
|
||
q.record = row.querySelector('input[data-k=record]').checked;
|
||
post("/api/streamer", q);
|
||
}
|
||
if(t.matches('select.brsel')){
|
||
// 档位下拉是选完立即生效的(正在录制时会自动换档重录)
|
||
var uid3 = parseInt(t.dataset.uid, 10);
|
||
var row3 = t.closest(".row");
|
||
// 选到「自定义」当场把码率框建出来并聚焦,切走就收掉(不依赖列表重建,见 showBrNum)
|
||
if(t.value === "custom") showBrNum(row3, t, true);
|
||
else hideBrNum(row3);
|
||
var q3 = rowQuality(row3);
|
||
if(!q3){ showMsg(false, "自定义档位的码率必须是大于等于 0 的数字"); return; }
|
||
q3.uid = uid3;
|
||
q3.danmu = row3.querySelector('input[data-k=danmu]').checked;
|
||
q3.record = row3.querySelector('input[data-k=record]').checked;
|
||
await post("/api/streamer", q3);
|
||
}
|
||
// 自定义档位那个码率框:回车或失焦即提交
|
||
if(t.matches('input.brnum')){
|
||
var uid5 = parseInt(t.dataset.uid, 10);
|
||
var v2 = parseInt(t.value.trim(), 10);
|
||
if(isNaN(v2) || v2 < 0){
|
||
showMsg(false, "自定义档位的码率必须是大于等于 0 的数字");
|
||
delete t.dataset.dirty;
|
||
// 数据没变化时列表不会重建,得当场把框里的内容恢复成服务端的值
|
||
var c5 = (lastSt && lastSt.streamers ? lastSt.streamers : []).filter(function(s){ return s.uid === uid5; })[0];
|
||
t.value = c5 ? (c5.bitrate || 0) : 0;
|
||
return;
|
||
}
|
||
var row4 = t.closest(".row");
|
||
var q4 = rowQuality(row4);
|
||
if(!q4) return;
|
||
q4.uid = uid5;
|
||
q4.danmu = row4.querySelector('input[data-k=danmu]').checked;
|
||
q4.record = row4.querySelector('input[data-k=record]').checked;
|
||
// 提交成功才算「不再是没提交的输入」,列表重建时才不会把它当成用户还在填
|
||
if(await post("/api/streamer", q4)) delete t.dataset.dirty;
|
||
}
|
||
});
|
||
// 去直播间接一次码率档位;拿到后下拉里就列出实际档位(0 = 自动第二高)
|
||
var qualitiesTried = {};
|
||
async function loadQualities(uid, quiet){
|
||
if(qualitiesTried[uid]) return;
|
||
qualitiesTried[uid] = true;
|
||
// 主播没开播时服务端要重试三次才放弃(约 5 秒),先给个反馈,别让人以为按钮没反应
|
||
if(!quiet) showMsg(true, "正在读取码率档位…");
|
||
var r = null;
|
||
try{ r = await api("/api/streamer/qualities?uid=" + uid); }catch(e){}
|
||
if(!r || !r.ok){
|
||
showMsg(false, r ? await r.text() : "连接失败");
|
||
qualitiesTried[uid] = false;
|
||
return;
|
||
}
|
||
var d = null;
|
||
try{ d = await r.json(); }catch(e){ return; }
|
||
showMsg(true, "已读取 " + (d.qualities || []).length + " 个码率档位");
|
||
await refresh();
|
||
}
|
||
// 开播但还没档位缓存时自动读一次,让下拉直接能用(这种必定成功,不用提示「正在读取」)
|
||
function autoLoadQualities(list){
|
||
(list || []).forEach(function(s){
|
||
if(s.live && !(s.qualities || []).length) loadQualities(s.uid, true);
|
||
});
|
||
}
|
||
// 回到地址栏或上次看的那个分区
|
||
showTab(location.hash.slice(1) || localStorage.getItem("acfunlive_tab") || "mon");
|
||
// 先试一次(用本地保存的口令),失败会自动弹出输入框并停止轮询
|
||
authed = true;
|
||
refresh();
|
||
setInterval(refresh, 3000);
|
||
|
||
// ===== 转换任务进度(进度、阶段、预计剩余、体积;暂停 / 停止)=====
|
||
// 结束之后还留一会儿,让用户看得到「已完成 / 已停止」的结果
|
||
var JOB_LINGER_MS = 20000;
|
||
// 自动任务结束后要播报一下,同一次任务只报一次
|
||
var jobAnnounced = "";
|
||
// 最近一次的任务快照,窗口尺寸变化时用它重画折线
|
||
var lastJob = null;
|
||
|
||
function fmtBytes(n){
|
||
if(!n || n <= 0) return "—";
|
||
var u = ["B","KB","MB","GB","TB"], i = 0;
|
||
while(n >= 1024 && i < u.length - 1){ n /= 1024; i++; }
|
||
return (i === 0 ? n : n.toFixed(n >= 100 ? 0 : 1)) + " " + u[i];
|
||
}
|
||
function fmtDur(sec){
|
||
if(!sec || sec <= 0 || !isFinite(sec)) return "—";
|
||
sec = Math.round(sec);
|
||
var h = Math.floor(sec/3600), m = Math.floor(sec%3600/60), s = sec%60;
|
||
var p = function(x){ return x < 10 ? "0" + x : "" + x; };
|
||
return (h ? h + ":" : "") + p(m) + ":" + p(s);
|
||
}
|
||
function fmtEta(sec){
|
||
if(!sec || sec <= 0 || !isFinite(sec)) return "剩余时间估算中…";
|
||
sec = Math.round(sec);
|
||
if(sec < 60) return "剩余约 " + sec + " 秒";
|
||
var m = Math.floor(sec/60), s = sec%60;
|
||
if(m < 60) return "剩余约 " + m + " 分" + (s ? " " + s + " 秒" : "");
|
||
var h = Math.floor(m/60);
|
||
return "剩余约 " + h + " 小时 " + (m%60) + " 分";
|
||
}
|
||
function renderJob(j){
|
||
lastJob = j; // 窗口宽度变了要按新宽度重画折线,留一份最近的数据
|
||
var card = document.getElementById("jobCard");
|
||
var topEl = document.getElementById("jobTop");
|
||
var recent = j.doneAt > 0 && (Date.now() - j.doneAt*1000) < JOB_LINGER_MS;
|
||
if(!j.active && !recent){
|
||
card.style.display = "none";
|
||
topEl.classList.remove("on");
|
||
return;
|
||
}
|
||
card.style.display = "";
|
||
|
||
var phases = j.phases || [];
|
||
var pct = Math.max(0, Math.min(100, j.percent || 0));
|
||
// 「被手动停止」不是失败,别当成错误报出来
|
||
var failed = !!j.err && j.err !== "任务已被手动停止";
|
||
|
||
// 批量任务(多个手动合并 / 多个转码)标出这是队列里的第几个
|
||
var q = (j.queueTotal > 1) ? "(第 " + Math.min((j.queueDone || 0) + 1, j.queueTotal) + "/" + j.queueTotal + " 个)" : "";
|
||
document.getElementById("jobOut").textContent =
|
||
(j.kind || "") + q + (j.outName ? " · " + j.outName : "");
|
||
|
||
// 控制按钮只在跑的时候出现;暂停中也允许直接停止(后台杀挂起的进程没问题)
|
||
var pb = document.getElementById("jobPause"), sb = document.getElementById("jobStop");
|
||
if(j.active){
|
||
pb.style.display = ""; sb.style.display = "";
|
||
pb.textContent = j.paused ? "继续" : "暂停";
|
||
pb.dataset.act = j.paused ? "resume" : "pause";
|
||
pb.disabled = !!j.stopping;
|
||
sb.disabled = !!j.stopping;
|
||
sb.title = "中断这次合并,已渲染的部分会被丢弃";
|
||
}else{
|
||
pb.style.display = "none";
|
||
sb.style.display = "none";
|
||
}
|
||
|
||
// 阶段步骤条:走过的打勾,当前的按是否暂停上色
|
||
document.getElementById("jobSteps").innerHTML = phases.map(function(name, i){
|
||
var cls = "step";
|
||
if(j.active){
|
||
if(i < j.phaseIdx) cls += " done";
|
||
else if(i === j.phaseIdx) cls += (j.paused ? " paused" : " on");
|
||
}else if(!failed || i < j.phaseIdx){
|
||
cls += " done";
|
||
}
|
||
return '<span class="' + cls + '"><i class="d"></i>' + esc(name) + '</span>';
|
||
}).join("");
|
||
|
||
document.getElementById("jobPct").textContent = pct.toFixed(pct >= 10 ? 0 : 1) + "%";
|
||
var phaseText = "";
|
||
if(!j.active){
|
||
// message 由服务端给(已完成 / 已停止 / 失败),只有真失败才补上原因
|
||
phaseText = j.message || "已完成";
|
||
if(j.err && j.message === "失败") phaseText += ":" + j.err;
|
||
}else if(j.paused){
|
||
phaseText = "已暂停(ffmpeg 已挂起,CPU 不再占用)";
|
||
}else if(j.stopping){
|
||
phaseText = "正在停止…";
|
||
}else if(j.phaseIdx >= 0 && j.phaseIdx < phases.length){
|
||
phaseText = "正在" + phases[j.phaseIdx];
|
||
if(j.phaseIdx === 2 && j.avatarTotal > 0)
|
||
phaseText += "(" + j.avatarDone + "/" + j.avatarTotal + " 个用户)";
|
||
if(j.phaseIdx === 3 && j.chunkTotal > 1)
|
||
phaseText += "(第 " + Math.min(j.chunkDone + 1, j.chunkTotal) + "/" + j.chunkTotal + " 块)";
|
||
}
|
||
document.getElementById("jobPhase").textContent = phaseText;
|
||
document.getElementById("jobEta").textContent =
|
||
(j.active && !j.paused) ? fmtEta(j.etaSec) : "";
|
||
|
||
var bar = document.getElementById("jobBarWrap");
|
||
document.getElementById("jobBar").style.width = pct + "%";
|
||
bar.className = "jbar" + (j.paused ? " paused" : "");
|
||
|
||
var cells = [];
|
||
var cell = function(k, v){ cells.push('<div class="jcell"><div class="k">' + k + '</div><div class="v">' + v + '</div></div>'); };
|
||
cell("渲染速度", j.speed > 0 ? j.speed.toFixed(2) + "x" : "—");
|
||
cell("帧率", j.fps > 0 ? j.fps.toFixed(1) + " fps" : "—");
|
||
cell("已处理时长", fmtDur(j.doneSec) + " / " + fmtDur(j.totalSec));
|
||
cell("已用时间", fmtDur(j.elapsedSec));
|
||
cell("已写出", fmtBytes(j.outBytes));
|
||
document.getElementById("jobNums").innerHTML = cells.join("");
|
||
|
||
// 源片 / 预计成片体积对比:两段按较大值等比缩放
|
||
var inB = j.inputBytes || 0, outB = j.estOutBytes || 0;
|
||
var vol = document.getElementById("jobVol");
|
||
if(inB > 0 || outB > 0){
|
||
vol.style.display = "";
|
||
var maxB = Math.max(inB, outB, 1);
|
||
document.getElementById("jobVolIn").style.width = (inB/maxB*100) + "%";
|
||
document.getElementById("jobVolOut").style.width = (outB/maxB*100) + "%";
|
||
document.getElementById("jobVolInText").textContent = fmtBytes(inB);
|
||
document.getElementById("jobVolOutText").textContent = fmtBytes(outB);
|
||
document.getElementById("jobVolNote").textContent = outB <= 0 ? ""
|
||
: (j.estSource === "measured" ? "(按已渲染部分推算)" : "(按目标码率估算)");
|
||
}else{
|
||
vol.style.display = "none";
|
||
}
|
||
|
||
// 渲染速度折线:横轴是任务已用时间,纵轴是倍速
|
||
var samples = j.samples || [];
|
||
var wrap = document.getElementById("jobSparkWrap");
|
||
if(samples.length >= 2){
|
||
wrap.style.display = "";
|
||
var svg = document.getElementById("jobSpark");
|
||
// viewBox 的宽度必须跟着元素的实际宽度走:写死一个值再靠 preserveAspectRatio="none"
|
||
// 铺满整行的话,宽屏上横轴被放大好几倍而纵轴不变,里面的文字会被拉扁
|
||
var W = Math.round(svg.getBoundingClientRect().width);
|
||
if(!W || W < 200) W = 300;
|
||
var H = 48, PAD = 5, maxS = 0;
|
||
samples.forEach(function(s){ if(s.speed > maxS) maxS = s.speed; });
|
||
if(maxS <= 0) maxS = 1;
|
||
var t0 = samples[0].t, span = Math.max(samples[samples.length-1].t - t0, 1);
|
||
var pts = samples.map(function(s){
|
||
var x = PAD + (s.t - t0)/span * (W - PAD*2);
|
||
var y = H - PAD - (s.speed/maxS) * (H - PAD*2);
|
||
return x.toFixed(1) + "," + y.toFixed(1);
|
||
}).join(" ");
|
||
var last = samples[samples.length-1];
|
||
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
|
||
svg.innerHTML =
|
||
'<polyline points="' + pts + '"/>' +
|
||
'<text x="' + PAD + '" y="12">峰值 ' + maxS.toFixed(2) + 'x</text>' +
|
||
'<text x="' + (W - PAD) + '" y="12" text-anchor="end">当前 ' + (last.speed || 0).toFixed(2) + 'x</text>';
|
||
}else{
|
||
wrap.style.display = "none";
|
||
}
|
||
|
||
if(j.active){
|
||
topEl.classList.add("on");
|
||
document.getElementById("jobTopBar").style.width = pct + "%";
|
||
}else{
|
||
topEl.classList.remove("on");
|
||
// 结束后播报一次(轮到哪个分区都看得见)
|
||
var key = String(j.doneAt);
|
||
if(jobAnnounced !== key){
|
||
jobAnnounced = key;
|
||
if(failed) showMsg(false, "转换任务失败:" + j.err);
|
||
else showMsg(true, "转换任务" + (j.message || "已完成"));
|
||
}
|
||
}
|
||
}
|
||
async function jobControl(act){
|
||
if(act === "stop" && !confirm("确定停止当前的合并任务吗?已经渲染的部分会被丢弃,不会生成成片。")) return;
|
||
var r = null;
|
||
try{
|
||
r = await api("/api/job/control", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({action: act})});
|
||
}catch(e){}
|
||
if(!r || !r.ok){ showMsg(false, r ? await r.text() : "操作失败"); return; }
|
||
showMsg(true, act === "pause" ? "已暂停" : (act === "resume" ? "已继续" : "正在停止…"));
|
||
pollJob();
|
||
}
|
||
// 有任务在跑就 1 秒刷一次,空闲时 4 秒
|
||
async function pollJob(){
|
||
var j = null;
|
||
if(authed){
|
||
try{
|
||
var r = await api("/api/job");
|
||
if(r.status === 401) showLogin("管理口令已失效,请重新输入");
|
||
else if(r.ok) j = await r.json();
|
||
}catch(e){}
|
||
if(j) renderJob(j);
|
||
}
|
||
setTimeout(pollJob, (j && j.active) ? 1000 : 4000);
|
||
}
|
||
pollJob();
|
||
// 折线图的 viewBox 是按元素宽度算的,窗口尺寸一变就得重画,否则文字又被拉扁
|
||
window.addEventListener("resize", function(){ if(lastJob) renderJob(lastJob); });
|
||
document.getElementById("tokenInput").addEventListener("keydown", function(e){ if(e.key === "Enter") login(); });
|
||
</script>
|
||
</body>
|
||
</html>`
|
||
|
||
// web UI 的主播状态
|
||
type webStreamer struct {
|
||
UID int `json:"uid"`
|
||
Name string `json:"name"`
|
||
Live bool `json:"live"`
|
||
Danmu bool `json:"danmu"` // 弹幕录制开关
|
||
Record bool `json:"record"` // 视频录制开关
|
||
QualityPref string `json:"qualityPref"` // 档位偏好:top / second / lowest / custom
|
||
Bitrate int `json:"bitrate"` // 自定义档位时的码率上限(Kbps)
|
||
Qualities []qualityTier `json:"qualities"` // 可选档位(按码率从高到低),没探测到时为空
|
||
QualityBitrate int `json:"qualityBitrate"` // 按偏好对应到的档位码率(没有档位表时为 0)
|
||
QualityName string `json:"qualityName"` // 对应到的档位名
|
||
DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕
|
||
RecordActive bool `json:"recordActive"` // 是否正在录制视频
|
||
Title string `json:"title"`
|
||
URL string `json:"url"`
|
||
}
|
||
|
||
// web UI 的状态数据
|
||
type webStatus struct {
|
||
UIDs []uidSetting `json:"uids"`
|
||
Directory string `json:"directory"`
|
||
AutoMerge bool `json:"autoMerge"`
|
||
MergeDelVideo bool `json:"mergeDelVideo"`
|
||
MergeDelDanmu bool `json:"mergeDelDanmu"`
|
||
MergeOutputDir string `json:"mergeOutputDir"`
|
||
MergeBitrate int `json:"mergeBitrate"`
|
||
MergeFps int `json:"mergeFps"`
|
||
DanmuFontSize int `json:"danmuFontSize"`
|
||
DefaultQualityPref string `json:"defaultQualityPref"`
|
||
DefaultBitrate int `json:"defaultBitrate"`
|
||
WebExternal bool `json:"webExternal"`
|
||
RecordSubdir string `json:"recordSubdir"`
|
||
MergeSubdir string `json:"mergeSubdir"`
|
||
FFmpegPath string `json:"ffmpegPath"`
|
||
RecordFormat string `json:"recordFormat"`
|
||
NotifyEnabled bool `json:"notifyEnabled"`
|
||
NotifyURL string `json:"notifyUrl"`
|
||
NotifyToken string `json:"notifyToken"`
|
||
NotifyGroup string `json:"notifyGroup"`
|
||
NotifyLiveOn bool `json:"notifyLiveOn"`
|
||
NotifyLiveOff bool `json:"notifyLiveOff"`
|
||
NotifyRecordStart bool `json:"notifyRecordStart"`
|
||
NotifyMergeDone bool `json:"notifyMergeDone"`
|
||
NotifyMergeFail bool `json:"notifyMergeFail"`
|
||
MeowEnabled bool `json:"meowEnabled"`
|
||
MeowNickname string `json:"meowNickname"`
|
||
MeowMsgType string `json:"meowMsgType"`
|
||
MeowHTMLHeight int `json:"meowHtmlHeight"`
|
||
MeowImgURL string `json:"meowImgUrl"`
|
||
MeowBlocked string `json:"meowBlocked"` // MeoW 推不出去的原因,空字符串表示正常
|
||
MergeBusy bool `json:"mergeBusy"` // 手动合并是否正在进行
|
||
MergeOutName string `json:"mergeOutName"` // 正在进行的手动合并的输出文件名
|
||
MergeErr string `json:"mergeErr"` // 最近一次手动合并的错误
|
||
MergeDoneAt int64 `json:"mergeDoneAt"` // 最近一次手动合并结束的时刻
|
||
MergeQueueDone int `json:"mergeQueueDone"` // 批量合并:已经做完第几个
|
||
MergeQueueTotal int `json:"mergeQueueTotal"` // 批量合并:一共几个
|
||
MergeResults []mergeJobResult `json:"mergeResults"` // 批量合并:每个任务的结果
|
||
|
||
TranscodeBusy bool `json:"transcodeBusy"` // 转码队列是否正在跑
|
||
TranscodeDone int `json:"transcodeDone"` // 已经转完第几个
|
||
TranscodeTotal int `json:"transcodeTotal"` // 一共几个
|
||
TranscodeCurrent string `json:"transcodeCurrent"` // 正在转的文件名
|
||
TranscodeResults []transcodeResult `json:"transcodeResults"` // 每个文件的结果
|
||
|
||
Streamers []webStreamer `json:"streamers"`
|
||
}
|
||
|
||
// 打印日志到 web UI
|
||
func lPrintln(msg ...any) {
|
||
logger.Println(msg...)
|
||
logString.Lock()
|
||
defer logString.Unlock()
|
||
// 防止内存泄漏
|
||
if logString.str.Len() > 1000000 {
|
||
logString.str.Reset()
|
||
}
|
||
n, _ := fmt.Fprint(&logString.str, getLogTime()+" ")
|
||
n2, _ := fmt.Fprintln(&logString.str, msg...)
|
||
logString.total += int64(n + n2)
|
||
}
|
||
|
||
// 显示 web UI 页面
|
||
func webuiHandler(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
fmt.Fprint(w, webuiHTML)
|
||
}
|
||
|
||
// 显示状态
|
||
func statusHandler(w http.ResponseWriter, r *http.Request) {
|
||
// 手动合并的状态在另一把锁上,先取了再用,不要在持 streamers 锁时去拿
|
||
mergeBusy, mergeOut, mergeErr, mergeDoneAt := mergeRunSnapshot()
|
||
_, mergeDone, mergeTotal, mergeResults := mergeQueueSnapshot()
|
||
tcBusy, tcDone, tcTotal, tcCurrent, tcResults := transcodeQueueSnapshot()
|
||
streamers.RLock()
|
||
status := webStatus{
|
||
UIDs: append(uidList{}, config.UIDs...),
|
||
Directory: config.Directory,
|
||
AutoMerge: config.AutoMerge,
|
||
MergeDelVideo: config.MergeDelVideo,
|
||
MergeDelDanmu: config.MergeDelDanmu,
|
||
MergeOutputDir: config.MergeOutputDir,
|
||
MergeBitrate: config.MergeBitrate,
|
||
MergeFps: config.MergeFps,
|
||
DanmuFontSize: config.DanmuFontSize,
|
||
WebExternal: config.WebExternal,
|
||
RecordSubdir: config.RecordSubdir,
|
||
MergeSubdir: config.MergeSubdir,
|
||
FFmpegPath: config.FFmpegPath,
|
||
RecordFormat: normalizeRecordFormat(config.RecordFormat),
|
||
DefaultQualityPref: normalizeQualityPref(config.DefaultQualityPref),
|
||
DefaultBitrate: config.DefaultBitrate,
|
||
NotifyEnabled: config.NotifyEnabled,
|
||
NotifyURL: config.NotifyURL,
|
||
NotifyToken: config.NotifyToken,
|
||
NotifyGroup: config.NotifyGroup,
|
||
NotifyLiveOn: config.NotifyLiveOn,
|
||
NotifyLiveOff: config.NotifyLiveOff,
|
||
NotifyRecordStart: config.NotifyRecordStart,
|
||
NotifyMergeDone: config.NotifyMergeDone,
|
||
NotifyMergeFail: config.NotifyMergeFail,
|
||
MeowEnabled: config.MeowEnabled,
|
||
MeowNickname: config.MeowNickname,
|
||
MeowMsgType: normalizeMeowMsgType(config.MeowMsgType),
|
||
MeowHTMLHeight: clampMeowHTMLHeight(config.MeowHTMLHeight),
|
||
MeowImgURL: config.MeowImgURL,
|
||
MeowBlocked: meowStatusProblem(),
|
||
MergeBusy: mergeBusy,
|
||
MergeOutName: mergeOut,
|
||
MergeErr: mergeErr,
|
||
MergeDoneAt: mergeDoneAt,
|
||
MergeQueueDone: mergeDone,
|
||
MergeQueueTotal: mergeTotal,
|
||
MergeResults: mergeResults,
|
||
TranscodeBusy: tcBusy,
|
||
TranscodeDone: tcDone,
|
||
TranscodeTotal: tcTotal,
|
||
TranscodeCurrent: tcCurrent,
|
||
TranscodeResults: tcResults,
|
||
Streamers: make([]webStreamer, 0, len(streamers.crt)),
|
||
}
|
||
for _, s := range streamers.crt {
|
||
live := s.isLiveOn()
|
||
var title string
|
||
if live {
|
||
title = s.getTitle()
|
||
}
|
||
ws := webStreamer{UID: s.UID, Name: s.Name, Live: live, Title: title, URL: s.getURL(), Danmu: s.Danmu, Record: s.Record,
|
||
QualityPref: normalizeQualityPref(s.QualityPref), Bitrate: s.Bitrate}
|
||
ws.Qualities = sortQualitiesDesc(s.Qualities)
|
||
ws.QualityBitrate = resolveQualityBitrate(s.Qualities, ws.QualityPref, ws.Bitrate)
|
||
ws.QualityName = qualityNameFor(s.Qualities, ws.QualityBitrate)
|
||
if infoList, ok := getLiveInfoByUID(s.UID); ok {
|
||
for _, info := range infoList {
|
||
if info.isDanmu {
|
||
ws.DanmuActive = true
|
||
}
|
||
if info.isRecording {
|
||
ws.RecordActive = true
|
||
}
|
||
}
|
||
}
|
||
status.Streamers = append(status.Streamers, ws)
|
||
}
|
||
streamers.RUnlock()
|
||
|
||
sort.Slice(status.Streamers, func(i, j int) bool {
|
||
return status.Streamers[i].UID < status.Streamers[j].UID
|
||
})
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
err := json.NewEncoder(w).Encode(status)
|
||
checkErr(err)
|
||
}
|
||
|
||
// 检查配置里的文件夹字段,返回错误信息,空字符串表示通过
|
||
func checkWebDir(path, name string) string {
|
||
if path == "" {
|
||
return ""
|
||
}
|
||
info, err := os.Stat(path)
|
||
if err != nil || !info.IsDir() {
|
||
return name + " 必须是存在的文件夹:" + path
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// 修改设置(保存目录和自动合并相关)
|
||
func webConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Directory string `json:"directory"`
|
||
AutoMerge bool `json:"autoMerge"`
|
||
MergeDelVideo bool `json:"mergeDelVideo"`
|
||
MergeDelDanmu bool `json:"mergeDelDanmu"`
|
||
MergeOutputDir string `json:"mergeOutputDir"`
|
||
MergeBitrate int `json:"mergeBitrate"`
|
||
MergeFps int `json:"mergeFps"`
|
||
DanmuFontSize int `json:"danmuFontSize"`
|
||
DefaultQualityPref string `json:"defaultQualityPref"`
|
||
DefaultBitrate int `json:"defaultBitrate"`
|
||
WebExternal bool `json:"webExternal"`
|
||
RecordSubdir string `json:"recordSubdir"`
|
||
MergeSubdir string `json:"mergeSubdir"`
|
||
FFmpegPath string `json:"ffmpegPath"`
|
||
RecordFormat string `json:"recordFormat"`
|
||
NotifyEnabled bool `json:"notifyEnabled"`
|
||
NotifyURL string `json:"notifyUrl"`
|
||
NotifyToken string `json:"notifyToken"`
|
||
NotifyGroup string `json:"notifyGroup"`
|
||
NotifyLiveOn bool `json:"notifyLiveOn"`
|
||
NotifyLiveOff bool `json:"notifyLiveOff"`
|
||
NotifyRecordStart bool `json:"notifyRecordStart"`
|
||
NotifyMergeDone bool `json:"notifyMergeDone"`
|
||
NotifyMergeFail bool `json:"notifyMergeFail"`
|
||
MeowEnabled bool `json:"meowEnabled"`
|
||
MeowNickname string `json:"meowNickname"`
|
||
MeowMsgType string `json:"meowMsgType"`
|
||
MeowHTMLHeight int `json:"meowHtmlHeight"`
|
||
MeowImgURL string `json:"meowImgUrl"`
|
||
}
|
||
err := json.NewDecoder(r.Body).Decode(&req)
|
||
if err != nil {
|
||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
if msg := checkWebDir(req.Directory, "directory"); msg != "" {
|
||
http.Error(w, msg, http.StatusBadRequest)
|
||
return
|
||
}
|
||
if msg := checkWebDir(req.MergeOutputDir, "mergeOutputDir"); msg != "" {
|
||
http.Error(w, msg, http.StatusBadRequest)
|
||
return
|
||
}
|
||
if req.MergeBitrate < 0 {
|
||
http.Error(w, "mergeBitrate 不能小于 0", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if req.DanmuFontSize < 0 {
|
||
http.Error(w, "danmuFontSize 不能小于 0(0 表示按画面分辨率自动)", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if req.MergeFps < 0 || req.MergeFps > maxMergeFps {
|
||
http.Error(w, fmt.Sprintf("mergeFps 只能在 0(跟随源片)到 %d 之间", maxMergeFps), http.StatusBadRequest)
|
||
return
|
||
}
|
||
if req.DefaultBitrate < 0 {
|
||
http.Error(w, "defaultBitrate 不能小于 0", http.StatusBadRequest)
|
||
return
|
||
}
|
||
recordSubdir := strings.TrimSpace(req.RecordSubdir)
|
||
mergeSubdir := strings.TrimSpace(req.MergeSubdir)
|
||
if recordSubdir == "" {
|
||
recordSubdir = defaultRecordSubdir
|
||
}
|
||
if mergeSubdir == "" {
|
||
mergeSubdir = defaultMergeSubdir
|
||
}
|
||
if isBadSubdir(recordSubdir) {
|
||
http.Error(w, "录播子文件夹名不能包含路径分隔符或非法字符:"+recordSubdir, http.StatusBadRequest)
|
||
return
|
||
}
|
||
if isBadSubdir(mergeSubdir) {
|
||
http.Error(w, "成片子文件夹名不能包含路径分隔符或非法字符:"+mergeSubdir, http.StatusBadRequest)
|
||
return
|
||
}
|
||
if recordSubdir == mergeSubdir && req.MergeOutputDir == "" {
|
||
http.Error(w, "录播子文件夹和成片子文件夹不能相同", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if msg := checkFFmpegPath(req.FFmpegPath); msg != "" {
|
||
http.Error(w, msg, http.StatusBadRequest)
|
||
return
|
||
}
|
||
recordFormat := strings.ToLower(strings.TrimSpace(req.RecordFormat))
|
||
if recordFormat == "" {
|
||
recordFormat = defaultRecordFormat
|
||
}
|
||
if _, ok := recordFormats[recordFormat]; !ok {
|
||
http.Error(w, "录播格式只能是 mp4、flv、ts 或 mkv:"+recordFormat, http.StatusBadRequest)
|
||
return
|
||
}
|
||
notifyURL := strings.TrimSpace(req.NotifyURL)
|
||
if msg := checkNotifyURL(notifyURL); msg != "" {
|
||
http.Error(w, msg, http.StatusBadRequest)
|
||
return
|
||
}
|
||
meowNickname := strings.TrimSpace(req.MeowNickname)
|
||
if msg := checkMeowNickname(meowNickname, true); msg != "" {
|
||
http.Error(w, msg, http.StatusBadRequest)
|
||
return
|
||
}
|
||
meowMsgType := strings.ToLower(strings.TrimSpace(req.MeowMsgType))
|
||
if meowMsgType == "" {
|
||
meowMsgType = meowMsgTypeText
|
||
}
|
||
if meowMsgType != meowMsgTypeText && meowMsgType != meowMsgTypeHTML {
|
||
http.Error(w, "MeoW 消息类型只能是 text 或 html:"+meowMsgType, http.StatusBadRequest)
|
||
return
|
||
}
|
||
if req.MeowHTMLHeight < 0 || req.MeowHTMLHeight > maxMeowHTMLHeight {
|
||
http.Error(w, fmt.Sprintf("MeoW 的 HTML 显示高度只能是 0(用默认 %d)到 %d 之间的整数",
|
||
defaultMeowHTMLHeight, maxMeowHTMLHeight), http.StatusBadRequest)
|
||
return
|
||
}
|
||
meowImgURL := strings.TrimSpace(req.MeowImgURL)
|
||
|
||
streamers.Lock()
|
||
config.Directory = req.Directory
|
||
config.AutoMerge = req.AutoMerge
|
||
config.MergeDelVideo = req.MergeDelVideo
|
||
config.MergeDelDanmu = req.MergeDelDanmu
|
||
config.MergeOutputDir = req.MergeOutputDir
|
||
config.MergeBitrate = req.MergeBitrate
|
||
config.MergeFps = req.MergeFps
|
||
config.DanmuFontSize = req.DanmuFontSize
|
||
config.DefaultQualityPref = normalizeQualityPref(req.DefaultQualityPref)
|
||
config.DefaultBitrate = req.DefaultBitrate
|
||
restartWeb := config.WebExternal != req.WebExternal
|
||
config.WebExternal = req.WebExternal
|
||
config.RecordSubdir = recordSubdir
|
||
config.MergeSubdir = mergeSubdir
|
||
config.FFmpegPath = strings.TrimSpace(req.FFmpegPath)
|
||
config.RecordFormat = recordFormat
|
||
config.NotifyEnabled = req.NotifyEnabled
|
||
config.NotifyURL = notifyURL
|
||
config.NotifyToken = strings.TrimSpace(req.NotifyToken)
|
||
config.NotifyGroup = strings.TrimSpace(req.NotifyGroup)
|
||
config.NotifyLiveOn = req.NotifyLiveOn
|
||
config.NotifyLiveOff = req.NotifyLiveOff
|
||
config.NotifyRecordStart = req.NotifyRecordStart
|
||
config.NotifyMergeDone = req.NotifyMergeDone
|
||
config.NotifyMergeFail = req.NotifyMergeFail
|
||
config.MeowEnabled = req.MeowEnabled
|
||
config.MeowNickname = meowNickname
|
||
config.MeowMsgType = meowMsgType
|
||
config.MeowHTMLHeight = clampMeowHTMLHeight(req.MeowHTMLHeight)
|
||
config.MeowImgURL = meowImgURL
|
||
// 修改了子文件夹名后确保新文件夹存在
|
||
if err := os.MkdirAll(recordingDir(), 0755); err != nil {
|
||
lPrintWarnf("创建文件夹 %s 失败:%v", recordingDir(), err)
|
||
}
|
||
if err := os.MkdirAll(recordSaveDir(), 0755); err != nil {
|
||
lPrintWarnf("创建文件夹 %s 失败:%v", recordSaveDir(), err)
|
||
}
|
||
if err := os.MkdirAll(mergeSaveDir(), 0755); err != nil {
|
||
lPrintWarnf("创建文件夹 %s 失败:%v", mergeSaveDir(), err)
|
||
}
|
||
streamers.Unlock()
|
||
|
||
saveConfig()
|
||
|
||
if restartWeb {
|
||
lPrintln("外网访问设置已改变,重启 web UI 服务器")
|
||
restartWebUI()
|
||
}
|
||
lPrintf("通过 web UI 修改了设置:目录 %q、自动合并 %v、合并删视频 %v、合并删弹幕 %v、成片目录 %q、合并码率 %d、输出帧率 %d、新主播默认档位 %s、外网访问 %v、录播子文件夹 %q、成片子文件夹 %q、ffmpeg 路径 %q、录播格式 %q",
|
||
req.Directory, req.AutoMerge, req.MergeDelVideo, req.MergeDelDanmu, req.MergeOutputDir,
|
||
req.MergeBitrate, req.MergeFps, qualityLogText(req.DefaultQualityPref, req.DefaultBitrate), req.WebExternal,
|
||
recordSubdir, mergeSubdir, config.FFmpegPath, recordFormat)
|
||
lPrintf("推送设置:QQ 机器人渠道 %v(地址 %q、群号 %q)、MeoW 渠道 %v(昵称 %q、类型 %s、高度 %d);事件 开播 %v、下播 %v、开始录制 %v、合并完成 %v、合并失败 %v",
|
||
req.NotifyEnabled, notifyURL, config.NotifyGroup, req.MeowEnabled, meowNickname, meowMsgType,
|
||
clampMeowHTMLHeight(req.MeowHTMLHeight),
|
||
req.NotifyLiveOn, req.NotifyLiveOff, req.NotifyRecordStart, req.NotifyMergeDone, req.NotifyMergeFail)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte("true"))
|
||
}
|
||
|
||
// 新增主播或修改主播的弹幕/录播开关和码率
|
||
// 档位探测的节流:同一个主播短时间内只去直播间接一次(页面上是点按钮才会来)
|
||
var qualitiesFetchMu struct {
|
||
sync.Mutex
|
||
at map[int]time.Time
|
||
}
|
||
|
||
// GET /api/streamer/qualities?uid=N —— 去直播间接一次该主播的码率档位。
|
||
// 拿到的档位会缓存进配置(没开播时页面也能显示下拉),还没设过码率的主播顺便按
|
||
// 默认档位(第二高)定下来。
|
||
func streamerQualitiesHandler(w http.ResponseWriter, r *http.Request) {
|
||
uid, err := strconv.Atoi(r.URL.Query().Get("uid"))
|
||
if err != nil || uid <= 0 {
|
||
http.Error(w, "uid 不合法", http.StatusBadRequest)
|
||
return
|
||
}
|
||
qualitiesFetchMu.Lock()
|
||
if qualitiesFetchMu.at == nil {
|
||
qualitiesFetchMu.at = make(map[int]time.Time)
|
||
}
|
||
last := qualitiesFetchMu.at[uid]
|
||
qualitiesFetchMu.Unlock()
|
||
if time.Since(last) < 10*time.Second {
|
||
http.Error(w, "刚读过一次档位,等几秒再试", http.StatusBadGateway)
|
||
return
|
||
}
|
||
|
||
tiers, err := fetchStreamQualities(uid)
|
||
if err != nil {
|
||
http.Error(w, "读取码率档位失败:"+err.Error(), http.StatusBadGateway)
|
||
return
|
||
}
|
||
qualitiesFetchMu.Lock()
|
||
qualitiesFetchMu.at[uid] = time.Now()
|
||
qualitiesFetchMu.Unlock()
|
||
|
||
if setStreamerQualities(uid, tiers) {
|
||
saveConfig()
|
||
}
|
||
pref := qualityPrefDefault
|
||
bitrate := 0
|
||
streamers.RLock()
|
||
if s, ok := streamers.crt[uid]; ok {
|
||
pref = normalizeQualityPref(s.QualityPref)
|
||
bitrate = s.Bitrate
|
||
}
|
||
streamers.RUnlock()
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
used := resolveQualityBitrate(tiers, pref, bitrate)
|
||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
"uid": uid,
|
||
"qualities": sortQualitiesDesc(tiers),
|
||
"qualityPref": pref,
|
||
"bitrate": bitrate,
|
||
"qualityBitrate": used,
|
||
"qualityName": qualityNameFor(tiers, used),
|
||
})
|
||
}
|
||
|
||
func webStreamerHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
UID int `json:"uid"`
|
||
Danmu bool `json:"danmu"`
|
||
Record bool `json:"record"`
|
||
QualityPref string `json:"qualityPref"` // top / second / lowest / custom,不传时用新主播的默认档位
|
||
Bitrate *int `json:"bitrate"` // 自定义档位时的码率上限,不传时用配置里的默认值
|
||
}
|
||
err := json.NewDecoder(r.Body).Decode(&req)
|
||
if err != nil {
|
||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
if req.UID <= 0 {
|
||
http.Error(w, "uid 必须大于 0", http.StatusBadRequest)
|
||
return
|
||
}
|
||
// 已存在的主播:不传档位时保持原样(页面切到「最高档」时不会把自定义码率清掉);
|
||
// 新增的主播才用配置里的默认档位
|
||
pref := normalizeQualityPref(config.DefaultQualityPref)
|
||
bitrate := config.DefaultBitrate
|
||
cur, exists := getStreamer(req.UID)
|
||
if exists {
|
||
pref = normalizeQualityPref(cur.QualityPref)
|
||
bitrate = cur.Bitrate
|
||
}
|
||
if strings.TrimSpace(req.QualityPref) != "" {
|
||
pref = normalizeQualityPref(req.QualityPref)
|
||
}
|
||
if req.Bitrate != nil {
|
||
bitrate = *req.Bitrate
|
||
}
|
||
if bitrate < 0 {
|
||
http.Error(w, "bitrate 不能小于 0", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// 档位变化时先停掉正在进行的录播,setStreamerFlags 之后用新档位重启
|
||
if exists && cur.Record && req.Record && (cur.QualityPref != pref || cur.Bitrate != bitrate) {
|
||
stopRecord(req.UID)
|
||
}
|
||
if !setStreamerFlags(req.UID, req.Danmu, req.Record) {
|
||
http.Error(w, "不存在 uid 为"+itoa(req.UID)+"的用户", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if cur, ok := getStreamer(req.UID); ok && (cur.QualityPref != pref || cur.Bitrate != bitrate) {
|
||
setQuality(req.UID, pref, bitrate)
|
||
}
|
||
lPrintf("通过 web UI 设置 uid %d:弹幕 %v、录播 %v、录制档位 %s", req.UID, req.Danmu, req.Record,
|
||
qualityLogText(pref, bitrate))
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte("true"))
|
||
}
|
||
|
||
// 停止或重新开始某个主播的录制
|
||
func webStreamerControlHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
UID int `json:"uid"`
|
||
Target string `json:"target"` // danmu、record 或 all
|
||
Action string `json:"action"` // stop 或 restart
|
||
}
|
||
err := json.NewDecoder(r.Body).Decode(&req)
|
||
if err != nil {
|
||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
if req.UID <= 0 {
|
||
http.Error(w, "uid 必须大于 0", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if err := controlStreamer(req.UID, req.Target, req.Action); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
actionName := "停止"
|
||
if req.Action == "restart" {
|
||
actionName = "重新开始"
|
||
}
|
||
lPrintf("通过 web UI %s uid %d 的录制(%s)", actionName, req.UID, req.Target)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte("true"))
|
||
}
|
||
|
||
// 删除主播
|
||
func webStreamerRemoveHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
UID int `json:"uid"`
|
||
}
|
||
err := json.NewDecoder(r.Body).Decode(&req)
|
||
if err != nil {
|
||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
removeUID(req.UID)
|
||
lPrintf("通过 web UI 删除了 uid %d", req.UID)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte("true"))
|
||
}
|
||
|
||
// 发送一条测试通知,验证推送地址、群号和口令是否配置正确
|
||
// 测试某个推送渠道能不能连通。
|
||
// 页面上填的值优先,留空的项用已经保存的配置补齐,所以改完不用先保存也能测
|
||
func notifyTestHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Channel string `json:"channel"` // qq(默认)或 meow
|
||
URL string `json:"url"`
|
||
Token string `json:"token"`
|
||
Group string `json:"group"`
|
||
Nickname string `json:"nickname"`
|
||
MsgType string `json:"msgType"`
|
||
ImgURL string `json:"imgUrl"`
|
||
Height int `json:"htmlHeight"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||
|
||
switch strings.ToLower(strings.TrimSpace(req.Channel)) {
|
||
case "", "qq", "webhook":
|
||
testNotifyQQ(w, req.URL, req.Token, req.Group)
|
||
case "meow":
|
||
testNotifyMeow(w, req.Nickname, req.MsgType, req.ImgURL, req.Height)
|
||
default:
|
||
http.Error(w, "未知的推送渠道:"+req.Channel, http.StatusBadRequest)
|
||
}
|
||
}
|
||
|
||
// 给 webhook 发一条测试推送
|
||
func testNotifyQQ(w http.ResponseWriter, addr, token, group string) {
|
||
n := currentNotifySetting()
|
||
if addr = strings.TrimSpace(addr); addr == "" {
|
||
addr = n.url
|
||
}
|
||
if token = strings.TrimSpace(token); token == "" {
|
||
token = n.token
|
||
}
|
||
if group = strings.TrimSpace(group); group == "" {
|
||
group = n.group
|
||
}
|
||
if addr == "" {
|
||
http.Error(w, "还没有填写通知推送地址", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if msg := checkNotifyURL(addr); msg != "" {
|
||
http.Error(w, msg, http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
p := notifyPayload{
|
||
Event: "test",
|
||
Name: "AcFun 直播录制",
|
||
Group: group,
|
||
Text: "这是一条测试通知,收到就说明推送地址和口令都配置正确",
|
||
Time: getLogTime(),
|
||
}
|
||
start := time.Now()
|
||
code, err := postNotify(addr, token, p)
|
||
if err != nil {
|
||
lPrintWarnf("推送测试通知失败:%v", err)
|
||
http.Error(w, "推送失败:"+err.Error(), http.StatusBadGateway)
|
||
return
|
||
}
|
||
lPrintln("测试通知推送成功:" + addr)
|
||
writeTestResult(w, fmt.Sprintf("webhook 返回 HTTP %d,用时 %s。地址和口令没问题,能不能收到还要看机器人那边有没有正常处理",
|
||
code, elapsedText(time.Since(start))))
|
||
}
|
||
|
||
// 给 MeoW 发一条测试推送
|
||
func testNotifyMeow(w http.ResponseWriter, nick, msgType, imgURL string, height int) {
|
||
n := currentNotifySetting()
|
||
if nick = strings.TrimSpace(nick); nick == "" {
|
||
nick = n.meowNick
|
||
}
|
||
if imgURL = strings.TrimSpace(imgURL); imgURL == "" {
|
||
imgURL = n.meowImg
|
||
}
|
||
if strings.TrimSpace(msgType) == "" {
|
||
msgType = n.meowMsgType
|
||
}
|
||
if height <= 0 {
|
||
height = n.meowHTML
|
||
}
|
||
if nick == "" {
|
||
http.Error(w, "还没有填写 MeoW 昵称", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if msg := checkMeowNickname(nick, false); msg != "" {
|
||
http.Error(w, msg, http.StatusBadRequest)
|
||
return
|
||
}
|
||
// 已经被 MeoW 判过「未注册」的昵称不再重试:官方提示一天内累计 3 次输错昵称就封 IP
|
||
if reason := meowBadNickReason(nick); reason != "" {
|
||
http.Error(w, fmt.Sprintf("昵称 %s 之前已被 MeoW 判定为%s,为避免 IP 被封不再重试。"+
|
||
"确认昵称没填错(或换一个昵称重新保存)就能恢复,重启程序也会清掉这个记录", nick, reason),
|
||
http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
msgType = normalizeMeowMsgType(msgType)
|
||
title := "测试推送"
|
||
plain := "这是一条测试通知,收到就说明 MeoW 渠道配置正确"
|
||
content := plain
|
||
if msgType == meowMsgTypeHTML {
|
||
content = meowHTMLText(title + "\n" + plain)
|
||
}
|
||
|
||
start := time.Now()
|
||
if err := pushMeow(nick, title, content, "", imgURL, msgType, height); err != nil {
|
||
lPrintWarnf("推送测试通知到 MeoW 失败:%v", err)
|
||
detail := "推送失败:" + err.Error()
|
||
if meowBadNickReason(nick) != "" {
|
||
detail += "。MeoW 一天内累计 3 次收到不存在的昵称就会封 IP,这里已经暂停向这个昵称推送"
|
||
}
|
||
http.Error(w, detail, http.StatusBadGateway)
|
||
return
|
||
}
|
||
lPrintf("测试通知推送成功(MeoW):%s", nick)
|
||
writeTestResult(w, fmt.Sprintf("MeoW 返回 status 200,用时 %s。手机上应该已经收到通知(昵称 %s)",
|
||
elapsedText(time.Since(start)), nick))
|
||
}
|
||
|
||
// 测试结果,detail 会显示在页面的提示里
|
||
func writeTestResult(w http.ResponseWriter, detail string) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "detail": detail})
|
||
}
|
||
|
||
func elapsedText(d time.Duration) string {
|
||
return fmt.Sprintf("%.2f 秒", d.Seconds())
|
||
}
|
||
|
||
// 显示日志,支持 ?since=<偏移量> 只返回新增的部分,避免每 3 秒全量传输
|
||
func logHandler(w http.ResponseWriter, r *http.Request) {
|
||
since, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
|
||
if err != nil {
|
||
since = 0
|
||
}
|
||
|
||
logString.Lock()
|
||
text := logString.str.String()
|
||
start := logString.total - int64(len(text)) // 缓冲区里现有内容对应的起始偏移
|
||
data := text
|
||
if since > start && since <= logString.total {
|
||
data = text[since-start:]
|
||
}
|
||
total := logString.total
|
||
logString.Unlock()
|
||
|
||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
// 用响应头返回新的偏移量:前端拿到的是解码后的字符串,长度不等于字节数
|
||
w.Header().Set("X-Log-Offset", strconv.FormatInt(total, 10))
|
||
fmt.Fprint(w, data)
|
||
}
|
||
|
||
// 启动 web UI 服务器
|
||
func startWebUI(ctx context.Context) {
|
||
defer func() {
|
||
if err := recover(); err != nil {
|
||
lPrintErr("Recovering from panic in startWebUI(), the error is:", err)
|
||
lPrintErr("web UI 服务器发生错误,尝试重启 web UI 服务器")
|
||
time.Sleep(2 * time.Second)
|
||
go startWebUI(ctx)
|
||
}
|
||
}()
|
||
|
||
addr := webUIAddr()
|
||
if config.WebExternal {
|
||
lPrintln("启动 web UI 服务器,监听 " + addr + ",局域网或外网设备可以通过本机 IP 访问 web UI 界面")
|
||
} else {
|
||
lPrintln("启动 web UI 服务器,只监听本机 " + addr + ",现在可以通过 http://localhost:" + itoa(config.WebPort) + " 访问 web UI 界面")
|
||
}
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("GET /{$}", webuiHandler)
|
||
mux.HandleFunc("GET /api/auth", authCheckHandler)
|
||
mux.HandleFunc("GET /api/auth/token", authTokenHandler)
|
||
mux.HandleFunc("POST /api/auth/token", authTokenSetHandler)
|
||
mux.HandleFunc("GET /api/status", statusHandler)
|
||
mux.HandleFunc("POST /api/config", webConfigHandler)
|
||
mux.HandleFunc("POST /api/streamer", webStreamerHandler)
|
||
mux.HandleFunc("GET /api/streamer/qualities", streamerQualitiesHandler)
|
||
mux.HandleFunc("POST /api/streamer/control", webStreamerControlHandler)
|
||
mux.HandleFunc("POST /api/streamer/remove", webStreamerRemoveHandler)
|
||
mux.HandleFunc("GET /api/log", logHandler)
|
||
mux.HandleFunc("POST /api/notify/test", notifyTestHandler)
|
||
mux.HandleFunc("GET /api/files", filesHandler)
|
||
mux.HandleFunc("GET /api/browse", browseHandler)
|
||
mux.HandleFunc("GET /api/file", fileDownloadHandler)
|
||
mux.HandleFunc("POST /api/file/remove", fileRemoveHandler)
|
||
mux.HandleFunc("POST /api/merge/info", mergeInfoHandler)
|
||
mux.HandleFunc("GET /api/merge/danmu", mergeDanmuMatchHandler)
|
||
mux.HandleFunc("POST /api/merge", mergeHandler)
|
||
mux.HandleFunc("POST /api/transcode/probe", transcodeProbeHandler)
|
||
mux.HandleFunc("POST /api/transcode/estimate", transcodeEstimateHandler)
|
||
mux.HandleFunc("POST /api/transcode", transcodeHandler)
|
||
mux.HandleFunc("GET /api/job", jobHandler)
|
||
mux.HandleFunc("POST /api/job/control", jobControlHandler)
|
||
|
||
srvCtx, srvCancel := context.WithCancel(ctx)
|
||
webSrvMu.Lock()
|
||
webSrvCancel = srvCancel
|
||
webSrvMu.Unlock()
|
||
|
||
srv := &http.Server{
|
||
Addr: addr,
|
||
IdleTimeout: 60 * time.Second,
|
||
// 没有这几个超时的话,慢速连接可以一直占着不放
|
||
ReadHeaderTimeout: 10 * time.Second,
|
||
ReadTimeout: 30 * time.Second,
|
||
Handler: webMiddleware(webAuth(mux)),
|
||
}
|
||
|
||
// 退出程序或主动重启时关闭 web UI 服务器
|
||
go func() {
|
||
<-srvCtx.Done()
|
||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||
lPrintErr("web UI 服务器关闭错误:", err)
|
||
}
|
||
}()
|
||
|
||
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||
// 主动重启导致的关闭不算错误
|
||
if srvCtx.Err() != nil {
|
||
lPrintln("停止 web UI 服务器")
|
||
return
|
||
}
|
||
lPrintErr("web UI 服务器出现错误:", err)
|
||
panic(err)
|
||
}
|
||
lPrintln("停止 web UI 服务器")
|
||
}
|
||
|
||
// web UI 监听地址,允许外网访问时监听所有网卡,否则只监听本机
|
||
func webUIAddr() string {
|
||
if config.WebExternal {
|
||
return ":" + itoa(config.WebPort)
|
||
}
|
||
return "127.0.0.1:" + itoa(config.WebPort)
|
||
}
|
||
|
||
// 从请求里取管理口令的所有可能写法,按优先级排:Authorization: Bearer、X-Auth-Token、?token=。
|
||
// 浏览器不允许把非 ISO-8859-1 的字符放进请求头(中文口令会直接抛 TypeError,请求根本发不出去),
|
||
// 所以页面会先把口令百分号编码再放进头里;这里把原样和解码后的都当候选,
|
||
// 这样中文口令能用,手写 curl / 脚本用原样口令也照样能用。
|
||
func authTokenCandidates(r *http.Request) []string {
|
||
var raw []string
|
||
if h := strings.TrimSpace(r.Header.Get("Authorization")); h != "" {
|
||
if len(h) > 7 && strings.EqualFold(h[:7], "Bearer ") {
|
||
raw = append(raw, strings.TrimSpace(h[7:]))
|
||
}
|
||
}
|
||
if t := strings.TrimSpace(r.Header.Get("X-Auth-Token")); t != "" {
|
||
raw = append(raw, t)
|
||
}
|
||
if t := strings.TrimSpace(r.URL.Query().Get("token")); t != "" {
|
||
raw = append(raw, t)
|
||
}
|
||
|
||
out := make([]string, 0, len(raw)*2)
|
||
for _, v := range raw {
|
||
out = append(out, v)
|
||
if d, err := url.QueryUnescape(v); err == nil && d != v {
|
||
out = append(out, d)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 校验请求里的管理口令,用固定时间比较避免通过响应时间猜口令
|
||
func checkAuthToken(r *http.Request) bool {
|
||
want := currentAuthToken()
|
||
if want == "" {
|
||
return false
|
||
}
|
||
// 逐个候选都比完再出结果,不提前返回,免得响应时间泄露是哪一种写法对上了
|
||
ok := false
|
||
for _, got := range authTokenCandidates(r) {
|
||
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 {
|
||
ok = true
|
||
}
|
||
}
|
||
return ok
|
||
}
|
||
|
||
// 口令错误次数的记录,给反复尝试的请求加一点延迟
|
||
var authFail struct {
|
||
sync.Mutex
|
||
m map[string]authFailEntry
|
||
sinceSweep int // 距上次扫表又记了多少条
|
||
}
|
||
|
||
type authFailEntry struct {
|
||
count int
|
||
last time.Time
|
||
}
|
||
|
||
// 口令错误时最多延迟这么久,避免拖垮服务器
|
||
const authMaxDelay = 2 * time.Second
|
||
|
||
const (
|
||
// 表里最多留这么多条。IP 是外部给的,必须有个硬上限:只靠过期清理的话,
|
||
// 每换一个新来源就多一条,清理追不上写入,最后把内存写满
|
||
authFailMaxEntries = 10000
|
||
// 超过这么久没再失败,这条记录就当没人用了
|
||
authFailExpire = 10 * time.Minute
|
||
// 同一个来源最多记到这个次数:再往上涨延迟也是封顶的,没必要让数字一直加
|
||
authFailMaxCount = 1000
|
||
// 每记这么多条扫一次表。扫一次要遍历(必要时还要排序),不必每次都做
|
||
authFailSweepEvery = 256
|
||
)
|
||
|
||
// 扫一遍口令错误记录:先删过期项,还多就把「最久没动静」的淘汰掉。
|
||
// 淘汰时留出一成余量,这样满了之后还能再收一千来个新来源才需要重排下一次全表,
|
||
// 不至于每来一个就把整张表排一遍(那反倒成了另一条拖慢服务的路子)。
|
||
func sweepAuthFail(now time.Time) {
|
||
for k, v := range authFail.m {
|
||
if now.Sub(v.last) > authFailExpire {
|
||
delete(authFail.m, k)
|
||
}
|
||
}
|
||
target := authFailMaxEntries * 9 / 10
|
||
over := len(authFail.m) - target
|
||
if over <= 0 {
|
||
return
|
||
}
|
||
type record struct {
|
||
ip string
|
||
last time.Time
|
||
}
|
||
all := make([]record, 0, len(authFail.m))
|
||
for k, v := range authFail.m {
|
||
all = append(all, record{k, v.last})
|
||
}
|
||
sort.Slice(all, func(i, j int) bool { return all[i].last.Before(all[j].last) })
|
||
for i := 0; i < over && i < len(all); i++ {
|
||
delete(authFail.m, all[i].ip)
|
||
}
|
||
}
|
||
|
||
// 等待口令错误的惩罚延迟。客户端一断开就立刻返回,
|
||
// 否则并发撞口令时一堆 goroutine 各占着一条连接干等两秒,很容易把服务拖住
|
||
func awaitAuthDelay(ctx context.Context, d time.Duration) bool {
|
||
if d <= 0 {
|
||
return true
|
||
}
|
||
timer := time.NewTimer(d)
|
||
defer timer.Stop()
|
||
select {
|
||
case <-timer.C:
|
||
return true
|
||
case <-ctx.Done():
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 取请求的来源 IP
|
||
func clientIP(r *http.Request) string {
|
||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||
return host
|
||
}
|
||
return r.RemoteAddr
|
||
}
|
||
|
||
// 记录一次口令错误并返回应该延迟的时长;口令正确时调用 authFailClear 清掉记录
|
||
func authFailDelay(ip string) time.Duration {
|
||
authFail.Lock()
|
||
defer authFail.Unlock()
|
||
if authFail.m == nil {
|
||
authFail.m = make(map[string]authFailEntry)
|
||
}
|
||
now := time.Now()
|
||
// 到上限就立刻扫一次。只靠定期扫的话,两个周期之间涌入的新来源
|
||
// 会把表顶破上限(IP 是外部给的,写入速度不受我们控制)
|
||
if len(authFail.m) >= authFailMaxEntries {
|
||
authFail.sinceSweep = 0
|
||
sweepAuthFail(now)
|
||
}
|
||
authFail.sinceSweep++
|
||
if authFail.sinceSweep >= authFailSweepEvery {
|
||
authFail.sinceSweep = 0
|
||
sweepAuthFail(now)
|
||
}
|
||
e := authFail.m[ip]
|
||
if e.count < authFailMaxCount {
|
||
e.count++
|
||
}
|
||
e.last = now
|
||
authFail.m[ip] = e
|
||
d := time.Duration(e.count) * 100 * time.Millisecond
|
||
if d > authMaxDelay {
|
||
d = authMaxDelay
|
||
}
|
||
return d
|
||
}
|
||
|
||
// 口令正确时清掉错误记录
|
||
func authFailClear(ip string) {
|
||
authFail.Lock()
|
||
defer authFail.Unlock()
|
||
delete(authFail.m, ip)
|
||
}
|
||
|
||
// 管理接口的口令校验:页面本身可以匿名打开(前端拿到 401 后再提示输入口令),
|
||
// 所有 /api/ 接口都必须带上正确的口令
|
||
func webAuth(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if !strings.HasPrefix(r.URL.Path, "/api/") {
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
ip := clientIP(r)
|
||
if checkAuthToken(r) {
|
||
authFailClear(ip)
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
if d := authFailDelay(ip); d > 0 {
|
||
// 客户端已经断开就不用再等了,直接收工
|
||
if !awaitAuthDelay(r.Context(), d) {
|
||
return
|
||
}
|
||
}
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
_, _ = w.Write([]byte(`{"error":"管理口令不正确,请在页面上重新输入"}`))
|
||
})
|
||
}
|
||
|
||
// 口令校验通过后才会走到这里,直接返回成功供前端登录使用
|
||
func authCheckHandler(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte("true"))
|
||
}
|
||
|
||
// 返回当前的管理口令,方便用户在设置区查看和复制
|
||
func authTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]string{"token": currentAuthToken()})
|
||
}
|
||
|
||
// 更换管理口令
|
||
func authTokenSetHandler(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Token string `json:"token"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
token := strings.TrimSpace(req.Token)
|
||
if len([]rune(token)) < 8 {
|
||
http.Error(w, "管理口令至少需要 8 个字符", http.StatusBadRequest)
|
||
return
|
||
}
|
||
streamers.Lock()
|
||
config.AuthToken = token
|
||
streamers.Unlock()
|
||
saveConfig()
|
||
lPrintln("通过 web UI 更换了管理口令(authToken)")
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte("true"))
|
||
}
|
||
|
||
// 统一的响应头和请求体限制
|
||
func webMiddleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
h := w.Header()
|
||
h.Set("X-Content-Type-Options", "nosniff")
|
||
h.Set("X-Frame-Options", "DENY")
|
||
h.Set("Referrer-Policy", "no-referrer")
|
||
// 页面用的是内联脚本和样式,所以只放行内联
|
||
h.Set("Content-Security-Policy",
|
||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self'")
|
||
// 写接口的请求体都很小,限制大小避免被慢速大请求拖着
|
||
if r.Method == http.MethodPost {
|
||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
var (
|
||
webSrvMu sync.Mutex
|
||
webSrvCancel context.CancelFunc
|
||
)
|
||
|
||
// 切换外网访问后监听地址会变,需要重启 web UI 服务器
|
||
func restartWebUI() {
|
||
webSrvMu.Lock()
|
||
cancel := webSrvCancel
|
||
webSrvMu.Unlock()
|
||
if cancel != nil {
|
||
cancel()
|
||
}
|
||
go func() {
|
||
time.Sleep(500 * time.Millisecond)
|
||
startWebUI(mainCtx)
|
||
}()
|
||
}
|