// 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 = ` AcFun 直播录制
AcFun 直播录制弹幕 / 视频录制,直播结束后自动合并

添加主播

填 uid 后默认同时开启弹幕和录播

主播列表

勾选 = 开启对应录制,改完立即生效
码率这里选的是直播的实际档位(如蓝光 4M / 高清),开播后自动读取,也能点每行的「档位」按钮立刻读一次。默认「自动 · 第二高」= 按这次读到档位排序自上往下取第二高(不一定有蓝光 8M,所以只按实际档位排),也可以直接选最高、最低或中间某一档。正在录制时换档会自动重录。

运行日志

手动合并的进度也在这里

    

保存位置

录播、弹幕和成片都放在这里
文本框改完按回车或点右上角「保存设置」生效,开关和下拉框改完立即生效;没保存就离开页面或刷新会丢掉改动。

录制参数

录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录。
录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。

自动合并

直播正常结束时的自动处理
三项互不影响,可以同时开启;需该主播同时开启弹幕和录播且都录到直播结束。手动停止或重新开始不会触发自动合并,那种情况用下面的手动合并。
同一主播下播后 20 秒内又上播会当成网络波动,接着录同一场,最终只合成一个成片。

成片输出

文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。

手动合并

自己挑视频和弹幕合成
自动合并只在录制自然结束时触发(手动停止过、弹幕和视频对不上号、或者把文件挪过位置的都不会自动合并),这些可以在这里自己挑文件合成。视频一行一个,填多个会按文件名里的录制时间顺序拼成一个再合成。合并进度见「监控」页的运行日志。
可多选,能换文件夹继续勾;选完追加到框里,不清掉已填内容

推送哪些事件

下面两个渠道共用这份勾选
推送失败只记警告,不影响录制。

渠道一 · QQ 机器人

自建机器人,走 HTTP POST JSON
推送体是 json,字段 event / uid / name / title / url / file / group / text / time,text 可以直接发给群。
用上面填写的地址发一条测试推送,不用先保存;改完记得点右上角「保存设置」

渠道二 · MeoW

手机上直接收通知
推给 MeoW 里这个昵称,点开通知可以跳回直播间;接口有频控(3 秒 1 条、每分钟 15 条、每小时 60 条)。
用上面填写的昵称发一条测试推送,不用先保存;改完记得按回车或点右上角「保存设置」

文件管理

正在读取磁盘信息…

访问方式

开启后监听所有网卡,同一局域网的设备可用本机 IP 访问;修改后 web 服务会自动重启,页面会自动重连。

管理口令

打开本页和管理录制都需要
忘记口令时可以打开程序所在目录的 config.json,查看 authToken 字段。

请输入管理口令

管理口令在程序首次启动时自动生成并打印在运行日志里,也可以在程序所在目录的 config.json 的 authToken 字段查看。

选择文件夹

逐级点选即可;也可以直接在地址栏里填或粘贴路径后按回车。选好后点下面的按钮填进设置框。

` // 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"` 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"` 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"` // 最近一次手动合并结束的时刻 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() 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, 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, 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, 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"` 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"` 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.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.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.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、新主播默认档位 %s、外网访问 %v、录播子文件夹 %q、成片子文件夹 %q、ffmpeg 路径 %q、录播格式 %q", req.Directory, req.AutoMerge, req.MergeDelVideo, req.MergeDelDanmu, req.MergeOutputDir, req.MergeBitrate, 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", req.NotifyEnabled, notifyURL, config.NotifyGroup, req.MeowEnabled, meowNickname, meowMsgType, clampMeowHTMLHeight(req.MeowHTMLHeight), req.NotifyLiveOn, req.NotifyLiveOff, req.NotifyRecordStart, req.NotifyMergeDone) 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) 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 } type authFailEntry struct { count int last time.Time } // 口令错误时最多延迟这么久,避免拖垮服务器 const authMaxDelay = 2 * time.Second // 取请求的来源 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) } // 记录太多时清掉过期的,避免大量不同来源的请求把内存撑满 if len(authFail.m) > 1000 { for k, v := range authFail.m { if time.Since(v.last) > 10*time.Minute { delete(authFail.m, k) } } } e := authFail.m[ip] e.count++ e.last = time.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 { time.Sleep(d) } 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) }() }