From 8817e538fb01e7409794394d1dad1542ab155316 Mon Sep 17 00:00:00 2001 From: Joftal <42807405+Joftal@users.noreply.github.com> Date: Tue, 26 May 2026 11:40:29 +0800 Subject: [PATCH 01/21] =?UTF-8?q?add=EF=BC=9A=E6=96=B0=E5=A2=9E=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=96=97=E9=B1=BC=E5=BC=B9=E5=B9=95=E5=BD=95=E5=88=B6?= =?UTF-8?q?=E3=80=82=E5=B9=B6=E4=BC=98=E5=8C=96=E5=BC=B9=E5=B9=95=E7=95=8C?= =?UTF-8?q?=E9=9D=A2UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/configs/config.go | 12 +- src/recorders/danmaku/douyu/client.go | 500 ++++++++++++++++++ src/recorders/danmaku/douyu_recorder.go | 151 ++++++ src/recorders/recorder.go | 33 +- src/servers/handler.go | 10 + .../src/component/danmaku-config/index.tsx | 136 +++-- 6 files changed, 790 insertions(+), 52 deletions(-) create mode 100644 src/recorders/danmaku/douyu/client.go create mode 100644 src/recorders/danmaku/douyu_recorder.go diff --git a/src/configs/config.go b/src/configs/config.go index 95d03da..e0ad46f 100644 --- a/src/configs/config.go +++ b/src/configs/config.go @@ -84,7 +84,8 @@ type DanmakuConfig struct { Resolution string `yaml:"resolution" json:"resolution"` // 播放分辨率 Outline int `yaml:"outline" json:"outline"` // 描边粗细 (0~4) Opacity int `yaml:"opacity" json:"opacity"` // 背景透明度 (0~255) - RecordGift *bool `yaml:"record_gift,omitempty" json:"record_gift,omitempty"` // 是否录制礼物 + RecordGift *bool `yaml:"record_gift,omitempty" json:"record_gift,omitempty"` // 是否录制礼物(哔哩哔哩) + RecordDouyuGift *bool `yaml:"record_douyu_gift,omitempty" json:"record_douyu_gift,omitempty"` // 是否录制礼物(斗鱼) RecordGuard *bool `yaml:"record_guard,omitempty" json:"record_guard,omitempty"` // 是否录制上舰 RecordSuperChat *bool `yaml:"record_super_chat,omitempty" json:"record_super_chat,omitempty"` // 是否录制SC GuardPosition string `yaml:"guard_position" json:"guard_position"` // 上舰位置: bottom-left, bottom-right, top-left, top-right @@ -102,6 +103,7 @@ var defaultDanmakuConfig = DanmakuConfig{ Outline: 1, Opacity: 128, RecordGift: BoolPtr(true), + RecordDouyuGift: BoolPtr(true), RecordGuard: BoolPtr(true), RecordSuperChat: BoolPtr(true), GuardPosition: "bottom-left", @@ -157,6 +159,9 @@ func (d *DanmakuConfig) SetDefaults() { if d.RecordGift == nil { d.RecordGift = BoolPtr(true) } + if d.RecordDouyuGift == nil { + d.RecordDouyuGift = BoolPtr(true) + } if d.RecordGuard == nil { d.RecordGuard = BoolPtr(true) } @@ -235,6 +240,9 @@ func mergeDanmakuConfig(base, override *DanmakuConfig) DanmakuConfig { if override.RecordGift != nil { result.RecordGift = override.RecordGift } + if override.RecordDouyuGift != nil { + result.RecordDouyuGift = override.RecordDouyuGift + } if override.RecordGuard != nil { result.RecordGuard = override.RecordGuard } @@ -426,7 +434,7 @@ type OverridableConfig struct { OnRecordFinished *OnRecordFinished `yaml:"on_record_finished,omitempty" json:"on_record_finished,omitempty"` // 录制完成后的动作 TimeoutInUs *int `yaml:"timeout_in_us,omitempty" json:"timeout_in_us,omitempty"` // 超时设置(微秒) StreamPreference *StreamPreference `yaml:"stream_preference,omitempty" json:"stream_preference,omitempty"` // 流偏好配置 - DanmakuEnable *bool `yaml:"danmaku_enable,omitempty" json:"danmaku_enable,omitempty"` // 是否录制弹幕(支持哔哩哔哩、抖音) + DanmakuEnable *bool `yaml:"danmaku_enable,omitempty" json:"danmaku_enable,omitempty"` // 是否录制弹幕(支持哔哩哔哩、抖音、斗鱼) Danmaku *DanmakuConfig `yaml:"danmaku,omitempty" json:"danmaku,omitempty"` // 弹幕录制参数 } diff --git a/src/recorders/danmaku/douyu/client.go b/src/recorders/danmaku/douyu/client.go new file mode 100644 index 0000000..d11c9dc --- /dev/null +++ b/src/recorders/danmaku/douyu/client.go @@ -0,0 +1,500 @@ +package douyu + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +const ( + douyuDanmuAddr = "danmuproxy.douyu.com:8601" + + msgTypeClient = 689 + msgTypeServer = 690 + + heartbeatInterval = 45 * time.Second + readTimeout = 60 * time.Second + + maxReconnect = 5 +) + +type DouyuClient struct { + roomID string + cookies string + conn net.Conn + onDanmaku func(username, content string, color int) + onGift func(username, giftName string, num int) + done chan struct{} + logger *logrus.Entry + mu sync.Mutex + running bool + cachedAddr string +} + +func NewDouyuClient(roomID, cookies string, onDanmaku func(username, content string, color int), onGift func(username, giftName string, num int), logger *logrus.Entry) *DouyuClient { + return &DouyuClient{ + roomID: roomID, + cookies: cookies, + onDanmaku: onDanmaku, + onGift: onGift, + done: make(chan struct{}), + logger: logger, + } +} + +func (c *DouyuClient) Start(ctx context.Context) error { + addr := c.resolveServerAddr() + + c.logger.Infof("连接斗鱼弹幕服务器: roomID=%s addr=%s", c.roomID, addr) + + conn, err := net.DialTimeout("tcp", addr, 10*time.Second) + if err != nil { + return fmt.Errorf("TCP 连接失败: %w", err) + } + + c.mu.Lock() + c.conn = conn + c.running = true + c.mu.Unlock() + + if err := c.sendLogin(); err != nil { + conn.Close() + return fmt.Errorf("发送登录消息失败: %w", err) + } + + resp, err := c.readOneFrame() + if err != nil { + conn.Close() + return fmt.Errorf("读取登录响应失败: %w", err) + } + fields := parseSTT(resp) + if fields["type"] != "loginres" { + conn.Close() + return fmt.Errorf("登录失败: %s", string(resp)) + } + c.logger.Debug("斗鱼弹幕登录成功") + + if err := c.sendJoinGroup(); err != nil { + conn.Close() + return fmt.Errorf("加入房间失败: %w", err) + } + + go c.readLoopWithReconnect(ctx) + go c.heartbeatLoop(ctx) + + c.logger.Info("斗鱼弹幕连接成功") + return nil +} + +func (c *DouyuClient) Stop() { + c.mu.Lock() + defer c.mu.Unlock() + if !c.running { + return + } + c.running = false + close(c.done) + if c.conn != nil { + c.conn.Close() + } +} + +func (c *DouyuClient) resolveServerAddr() string { + if c.cachedAddr != "" { + return c.cachedAddr + } + + type serverInfo struct { + IP string `json:"ip"` + Port string `json:"port"` + } + + client := &http.Client{Timeout: 5 * time.Second} + req, err := http.NewRequest("GET", "https://www.douyu.com/betard/"+c.roomID, nil) + if err != nil { + return douyuDanmuAddr + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + if c.cookies != "" { + req.Header.Set("Cookie", c.cookies) + } + + resp, err := client.Do(req) + if err != nil { + c.logger.WithError(err).Debug("获取弹幕服务器列表失败,使用默认地址") + return douyuDanmuAddr + } + defer resp.Body.Close() + + var result struct { + Room struct { + ServerConfig string `json:"server_config"` + } `json:"room"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return douyuDanmuAddr + } + if result.Room.ServerConfig == "" { + return douyuDanmuAddr + } + + var servers []serverInfo + if err := json.Unmarshal([]byte(result.Room.ServerConfig), &servers); err != nil { + return douyuDanmuAddr + } + if len(servers) == 0 { + return douyuDanmuAddr + } + + s := servers[0] + addr := net.JoinHostPort(s.IP, s.Port) + c.cachedAddr = addr + c.logger.Debugf("从 API 获取弹幕服务器: %s", addr) + return addr +} + +func (c *DouyuClient) invalidateAddrCache() { + c.cachedAddr = "" +} + +func (c *DouyuClient) readLoopWithReconnect(ctx context.Context) { + defer func() { + c.mu.Lock() + c.running = false + c.mu.Unlock() + }() + + reconnectCount := 0 + + for { + select { + case <-ctx.Done(): + return + case <-c.done: + return + default: + } + + err := c.readLoop(ctx) + if err == nil { + return + } + + select { + case <-ctx.Done(): + return + case <-c.done: + return + default: + } + + reconnectCount++ + if reconnectCount > maxReconnect { + c.logger.Errorf("重连 %d 次仍然失败,停止弹幕录制", maxReconnect) + return + } + + c.logger.Warnf("连接断开,%d秒后第 %d 次重连...", 3*reconnectCount, reconnectCount) + + select { + case <-ctx.Done(): + return + case <-c.done: + return + case <-time.After(time.Duration(3*reconnectCount) * time.Second): + } + + c.invalidateAddrCache() + newAddr := c.resolveServerAddr() + conn, dialErr := net.DialTimeout("tcp", newAddr, 10*time.Second) + if dialErr != nil { + c.logger.WithError(dialErr).Warn("重连失败") + continue + } + + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + } + c.conn = conn + c.mu.Unlock() + + if loginErr := c.sendLogin(); loginErr != nil { + c.logger.WithError(loginErr).Warn("重连后登录失败") + continue + } + resp, readErr := c.readOneFrame() + if readErr != nil { + c.logger.WithError(readErr).Warn("重连后读取登录响应失败") + continue + } + respFields := parseSTT(resp) + if respFields["type"] != "loginres" { + c.logger.Warnf("重连后登录失败: %s", string(resp)) + continue + } + if joinErr := c.sendJoinGroup(); joinErr != nil { + c.logger.WithError(joinErr).Warn("重连后加入房间失败") + continue + } + + reconnectCount = 0 + c.logger.Info("重连成功") + } +} + +func (c *DouyuClient) readLoop(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return nil + case <-c.done: + return nil + default: + } + + c.mu.Lock() + if !c.running || c.conn == nil { + c.mu.Unlock() + return nil + } + c.conn.SetReadDeadline(time.Now().Add(readTimeout)) + frameType, body, err := c.readFrameLocked() + c.mu.Unlock() + + if err != nil { + return fmt.Errorf("读取帧失败: %w", err) + } + + if frameType != msgTypeServer { + continue + } + + fields := parseSTT(body) + msgType := fields["type"] + if msgType == "" { + continue + } + + switch msgType { + case "chatmsg": + nn := fields["nn"] + txt := fields["txt"] + if nn != "" && txt != "" && c.onDanmaku != nil { + col := parseDouyuColor(fields["col"]) + c.onDanmaku(nn, txt, col) + } + case "dgb": + if c.onGift != nil { + nn := fields["nn"] + gfn := fields["gfn"] + gfcnt := fields["gfcnt"] + if nn != "" && gfn != "" { + num := 1 + if n, err := strconv.Atoi(gfcnt); err == nil && n > 0 { + num = n + } + c.onGift(nn, gfn, num) + } + } + case "pingreq": + c.sendKeepAlive() + case "kickmsg": + c.logger.Warn("被服务器踢出") + return fmt.Errorf("kicked by server") + case "error": + c.logger.Warnf("服务器错误: %s", string(body)) + return fmt.Errorf("server error: %s", string(body)) + } + } +} + +func (c *DouyuClient) heartbeatLoop(ctx context.Context) { + ticker := time.NewTicker(heartbeatInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-c.done: + return + case <-ticker.C: + c.sendKeepAlive() + } + } +} + +func (c *DouyuClient) sendKeepAlive() { + c.mu.Lock() + defer c.mu.Unlock() + if !c.running || c.conn == nil { + return + } + c.sendFrameLocked(encodeSTT("type", "keeplive", "tick", fmt.Sprintf("%d", time.Now().UnixMilli()))) +} + +func (c *DouyuClient) sendLogin() error { + c.mu.Lock() + defer c.mu.Unlock() + if !c.running || c.conn == nil { + return fmt.Errorf("client not running") + } + return c.sendFrameLocked(encodeSTT("type", "loginreq", "roomid", c.roomID)) +} + +func (c *DouyuClient) sendJoinGroup() error { + c.mu.Lock() + defer c.mu.Unlock() + if !c.running || c.conn == nil { + return fmt.Errorf("client not running") + } + return c.sendFrameLocked(encodeSTT("type", "joingroup", "rid", c.roomID, "gid", "-9999")) +} + +func (c *DouyuClient) sendFrameLocked(data []byte) error { + c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + _, err := c.conn.Write(data) + return err +} + +func (c *DouyuClient) readOneFrame() ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + if !c.running || c.conn == nil { + return nil, fmt.Errorf("client not running") + } + c.conn.SetReadDeadline(time.Now().Add(readTimeout)) + _, body, err := c.readFrameLocked() + return body, err +} + +// readFrameLocked reads a single Douyu frame from c.conn. +// Caller must hold c.mu and have set the read deadline. +func (c *DouyuClient) readFrameLocked() (int, []byte, error) { + header := make([]byte, 12) + if _, err := c.readFullLocked(header); err != nil { + return 0, nil, err + } + length := binary.LittleEndian.Uint32(header[0:4]) + msgType := int(binary.LittleEndian.Uint32(header[8:12])) + bodyLen := int(length) - 8 + if bodyLen < 0 || bodyLen > 100000 { + return msgType, nil, fmt.Errorf("invalid body length: %d", bodyLen) + } + body := make([]byte, bodyLen) + if _, err := c.readFullLocked(body); err != nil { + return msgType, nil, err + } + if len(body) > 0 && body[len(body)-1] == 0 { + body = body[:len(body)-1] + } + return msgType, body, nil +} + +// readFullLocked reads exactly len(buf) bytes from c.conn. +// Caller must hold c.mu. +func (c *DouyuClient) readFullLocked(buf []byte) (int, error) { + total := 0 + for total < len(buf) { + n, err := c.conn.Read(buf[total:]) + total += n + if err != nil { + return total, err + } + } + return total, nil +} + +// encodeSTT encodes key-value pairs into Douyu STT binary frame. +// Each pair is "key@=value/", the whole message ends with "\x00". +// The frame is wrapped with a 12-byte header: length(4) + length(4) + type(4). +func encodeSTT(pairs ...string) []byte { + if len(pairs)%2 != 0 { + pairs = pairs[:len(pairs)-1] + } + var sb strings.Builder + for i := 0; i+1 < len(pairs); i += 2 { + sb.WriteString(pairs[i]) + sb.WriteString("@=") + sb.WriteString(escapeSTTValue(pairs[i+1])) + sb.WriteByte('/') + } + sb.WriteByte(0) + body := []byte(sb.String()) + length := uint32(len(body) + 8) + buf := make([]byte, 12+len(body)) + binary.LittleEndian.PutUint32(buf[0:4], length) + binary.LittleEndian.PutUint32(buf[4:8], length) + binary.LittleEndian.PutUint32(buf[8:12], msgTypeClient) + copy(buf[12:], body) + return buf +} + +// parseSTT parses Douyu STT-encoded body into a map. +// Format: "key1@=value1/key2@=value2/" +func parseSTT(body []byte) map[string]string { + result := make(map[string]string) + s := string(body) + if s == "" { + return result + } + segments := strings.Split(s, "/") + for _, seg := range segments { + if seg == "" { + continue + } + idx := strings.Index(seg, "@=") + if idx < 0 { + continue + } + key := seg[:idx] + val := unescapeSTTValue(seg[idx+2:]) + if key != "" { + result[key] = val + } + } + return result +} + +func escapeSTTValue(s string) string { + s = strings.ReplaceAll(s, "@", "@A") + s = strings.ReplaceAll(s, "/", "@S") + return s +} + +func unescapeSTTValue(s string) string { + s = strings.ReplaceAll(s, "@A", "@") + s = strings.ReplaceAll(s, "@S", "/") + return s +} + +// parseDouyuColor maps Douyu color index to ASS RGB color (BBGGRR format). +// Douyu uses small integers as color indices, not direct RGB values. +func parseDouyuColor(col string) int { + switch col { + case "1": // red (RGB #FF0000 → BBGGRR 0x0000FF) + return 0x0000FF + case "2": // orange (RGB #FF8C00 → BBGGRR 0x008CFF) + return 0x008CFF + case "3": // green (RGB #00FF00 → BBGGRR 0x00FF00) + return 0x00FF00 + case "4": // yellow (RGB #FFFF00 → BBGGRR 0x00FFFF) + return 0x00FFFF + case "5": // purple (RGB #FF00FF → BBGGRR 0xFF00FF) + return 0xFF00FF + case "6": // cyan (RGB #00FFFF → BBGGRR 0xFFFF00) + return 0xFFFF00 + default: // white (RGB #FFFFFF → BBGGRR 0xFFFFFF) + return 16777215 + } +} diff --git a/src/recorders/danmaku/douyu_recorder.go b/src/recorders/danmaku/douyu_recorder.go new file mode 100644 index 0000000..8cbb1b4 --- /dev/null +++ b/src/recorders/danmaku/douyu_recorder.go @@ -0,0 +1,151 @@ +package danmaku + +import ( + "context" + "sync" + "time" + + "github.com/sirupsen/logrus" + + "github.com/bililive-go/bililive-go/src/configs" + "github.com/bililive-go/bililive-go/src/recorders/danmaku/douyu" +) + +type DouyuDanmakuRecorder struct { + roomID string + cookies string + outputFile string + cfg configs.DanmakuConfig + startAt time.Time + assWriter *AssWriter + client *douyu.DouyuClient + logger *logrus.Entry + count int + mu sync.Mutex + running bool +} + +func NewDouyuDanmakuRecorder(roomID, cookies, outputFile string, cfg configs.DanmakuConfig, logger *logrus.Entry) *DouyuDanmakuRecorder { + return &DouyuDanmakuRecorder{ + roomID: roomID, + cookies: cookies, + outputFile: outputFile, + cfg: cfg, + logger: logger, + } +} + +func (r *DouyuDanmakuRecorder) Start(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.running { + return nil + } + + startAt := time.Now() + + assWriter, err := NewAssWriter(r.outputFile, startAt, r.cfg, "Douyu Danmaku") + if err != nil { + return err + } + r.assWriter = assWriter + + var onGift func(username, giftName string, num int) + if r.cfg.RecordDouyuGift != nil && *r.cfg.RecordDouyuGift { + onGift = r.onGift + } + + r.client = douyu.NewDouyuClient(r.roomID, r.cookies, r.onDanmaku, onGift, r.logger) + + if err := r.client.Start(ctx); err != nil { + assWriter.Close() + return err + } + + r.startAt = startAt + r.running = true + r.logger.Info("斗鱼弹幕录制已启动") + + return nil +} + +func (r *DouyuDanmakuRecorder) Stop() { + r.mu.Lock() + if !r.running { + r.mu.Unlock() + return + } + + r.running = false + client := r.client + writer := r.assWriter + r.client = nil + r.assWriter = nil + count := r.count + r.mu.Unlock() + + if client != nil { + client.Stop() + } + if writer != nil { + writer.Close() + } + + r.logger.Infof("斗鱼弹幕录制已停止,共录制 %d 条弹幕", count) +} + +func (r *DouyuDanmakuRecorder) OutputFile() string { + return r.outputFile +} + +func (r *DouyuDanmakuRecorder) GetCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.count +} + +func (r *DouyuDanmakuRecorder) IsRunning() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.running +} + +func (r *DouyuDanmakuRecorder) GetStatus() map[string]interface{} { + r.mu.Lock() + defer r.mu.Unlock() + + status := map[string]interface{}{ + "danmaku_running": r.running, + "danmaku_count": r.count, + "danmaku_output": r.outputFile, + } + if r.running { + status["danmaku_start_time"] = r.startAt.Format(time.RFC3339) + } + return status +} + +func (r *DouyuDanmakuRecorder) onDanmaku(username, content string, color int) { + r.mu.Lock() + defer r.mu.Unlock() + + if !r.running || r.assWriter == nil { + return + } + + r.assWriter.AddDanmaku(time.Now(), username, content, color) + r.count++ +} + +func (r *DouyuDanmakuRecorder) onGift(username, giftName string, num int) { + r.mu.Lock() + defer r.mu.Unlock() + + if !r.running || r.assWriter == nil { + return + } + + r.assWriter.AddGift(time.Now(), username, giftName, num) + r.count++ +} diff --git a/src/recorders/recorder.go b/src/recorders/recorder.go index e04e0f7..090f06b 100644 --- a/src/recorders/recorder.go +++ b/src/recorders/recorder.go @@ -500,7 +500,7 @@ func (r *recorder) tryRecord(ctx context.Context) { r.setAndCloseParser(p) r.startTime = time.Now() - // 弹幕录制(支持哔哩哔哩和抖音平台) + // 弹幕录制(支持哔哩哔哩、抖音、斗鱼平台) if resolvedConfig.DanmakuEnable { switch r.Live.GetPlatformCNName() { case "哔哩哔哩": @@ -547,6 +547,27 @@ func (r *recorder) tryRecord(ctx context.Context) { } else { r.getLogger().Warn("弹幕录制已启用但无法解析房间ID: " + r.Live.GetRawUrl()) } + case "斗鱼": + assFile := fileName[:strings.LastIndex(fileName, ".")] + ".ass" + roomID := extractDouyuRoomID(r.Live) + cookies := extractCookiesString(r.Live) + if roomID != "" { + r.getLogger().Infof("弹幕录制已启用,房间ID: %s, 输出: %s", roomID, assFile) + rec := danmaku.NewDouyuDanmakuRecorder(roomID, cookies, assFile, resolvedConfig.Danmaku, r.getLogger().Entry) + if dmErr := rec.Start(ctx); dmErr != nil { + r.getLogger().WithError(dmErr).Warn("弹幕录制启动失败,继续录制视频") + } else { + r.currentFileLock.Lock() + old := r.danmakuRec + r.danmakuRec = rec + r.currentFileLock.Unlock() + if old != nil { + old.Stop() + } + } + } else { + r.getLogger().Warn("弹幕录制已启用但无法解析房间ID: " + r.Live.GetRawUrl()) + } } } else { // 弹幕未启用,清理旧的录制器 @@ -1439,3 +1460,13 @@ func extractDouyinRoomID(l live.Live) string { } return roomID } + +// extractDouyuRoomID 从斗鱼直播 URL 中提取房间号(字符串)。 +// 斗鱼 URL 格式: https://www.douyu.com/123456 +func extractDouyuRoomID(l live.Live) string { + u, err := url.Parse(l.GetRawUrl()) + if err != nil { + return "" + } + return strings.Trim(u.Path, "/") +} diff --git a/src/servers/handler.go b/src/servers/handler.go index e82069d..8cbaccb 100644 --- a/src/servers/handler.go +++ b/src/servers/handler.go @@ -1442,6 +1442,11 @@ func applyConfigUpdates(c *configs.Config, updates map[string]interface{}) error } else if _, exists := danmaku["record_gift"]; exists && danmaku["record_gift"] == nil { c.Danmaku.RecordGift = nil } + if recordDouyuGift, ok := danmaku["record_douyu_gift"].(bool); ok { + c.Danmaku.RecordDouyuGift = configs.BoolPtr(recordDouyuGift) + } else if _, exists := danmaku["record_douyu_gift"]; exists && danmaku["record_douyu_gift"] == nil { + c.Danmaku.RecordDouyuGift = nil + } if recordGuard, ok := danmaku["record_guard"].(bool); ok { c.Danmaku.RecordGuard = configs.BoolPtr(recordGuard) } else if _, exists := danmaku["record_guard"]; exists && danmaku["record_guard"] == nil { @@ -1995,6 +2000,11 @@ func applyOverridableConfigUpdates(oc *configs.OverridableConfig, updates map[st } else if _, exists := danmaku["record_gift"]; exists && danmaku["record_gift"] == nil { oc.Danmaku.RecordGift = nil } + if recordDouyuGift, ok := danmaku["record_douyu_gift"].(bool); ok { + oc.Danmaku.RecordDouyuGift = configs.BoolPtr(recordDouyuGift) + } else if _, exists := danmaku["record_douyu_gift"]; exists && danmaku["record_douyu_gift"] == nil { + oc.Danmaku.RecordDouyuGift = nil + } if recordGuard, ok := danmaku["record_guard"].(bool); ok { oc.Danmaku.RecordGuard = configs.BoolPtr(recordGuard) } else if _, exists := danmaku["record_guard"]; exists && danmaku["record_guard"] == nil { diff --git a/src/webapp/src/component/danmaku-config/index.tsx b/src/webapp/src/component/danmaku-config/index.tsx index 6f7a351..e0a2cbd 100644 --- a/src/webapp/src/component/danmaku-config/index.tsx +++ b/src/webapp/src/component/danmaku-config/index.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { - Card, Form, Switch, InputNumber, Select, Button, message, Spin, Collapse, Tag, Popconfirm, Space, Divider, Input + Card, Form, Switch, InputNumber, Select, Button, message, Spin, Collapse, Tag, Popconfirm, Space, Input } from 'antd'; import { UndoOutlined } from '@ant-design/icons'; import API from '../../utils/api'; @@ -16,6 +16,7 @@ const DEFAULT_DANMAKU: DanmakuConfig = { outline: 1, opacity: 128, record_gift: true, + record_douyu_gift: true, record_guard: true, record_super_chat: true, guard_position: 'bottom-left', @@ -31,6 +32,7 @@ interface DanmakuConfig { outline: number; opacity: number; record_gift: boolean; + record_douyu_gift: boolean; record_guard: boolean; record_super_chat: boolean; guard_position: string; @@ -75,7 +77,8 @@ const DanmakuParamForm: React.FC<{ label?: string; isRoom?: boolean; showBilibiliContent?: boolean; -}> = ({ initialValues, globalDefaults, onSave, onReset, loading, showEnable, danmakuEnable, label, isRoom, showBilibiliContent }) => { + showDouyuContent?: boolean; +}> = ({ initialValues, globalDefaults, onSave, onReset, loading, showEnable, danmakuEnable, label, isRoom, showBilibiliContent, showDouyuContent }) => { const [form] = Form.useForm(); const baseDefaults = useMemo(() => globalDefaults || DEFAULT_DANMAKU, [globalDefaults]); @@ -200,50 +203,80 @@ const DanmakuParamForm: React.FC<{ {showBilibiliContent && ( - <> - 哔哩哔哩录制内容 + +
+ 礼物消息 用户赠送礼物的通知} + name={['danmaku', 'record_gift']} valuePropName="checked"> + + + 上舰消息 开通舰长/提督/总督的通知} + name={['danmaku', 'record_guard']} valuePropName="checked"> + + + 醒目留言 (SC) Super Chat 付费留言} + name={['danmaku', 'record_super_chat']} valuePropName="checked"> + + +
-
- 礼物消息 用户赠送礼物的通知} - name={['danmaku', 'record_gift']} valuePropName="checked"> - - - 上舰消息 开通舰长/提督/总督的通知} - name={['danmaku', 'record_guard']} valuePropName="checked"> - - - 醒目留言 (SC) Super Chat 付费留言} - name={['danmaku', 'record_super_chat']} valuePropName="checked"> - - -
+
+ + + +
+ + ), + }]} + /> + )} -
- - - -
- + {showDouyuContent && ( + + 礼物消息 用户赠送礼物的通知} + name={['danmaku', 'record_douyu_gift']} valuePropName="checked"> + + + + ), + }]} + /> )} @@ -271,6 +304,7 @@ const DanmakuParamForm: React.FC<{ const DANMAKU_PLATFORMS: Record = { bilibili: '哔哩哔哩', douyin: '抖音', + douyu: '斗鱼', }; const DanmakuSettings: React.FC = () => { @@ -436,6 +470,7 @@ const DanmakuSettings: React.FC = () => { loading={saving} label="全局弹幕" showBilibiliContent + showDouyuContent /> @@ -521,9 +556,11 @@ const DanmakuSettings: React.FC = () => { - {Object.entries(platformRooms).map(([platformKey, rooms]) => ( - rooms.length > 0 && ( - + {Object.keys(DANMAKU_PLATFORMS).map((platformKey) => { + const rooms = platformRooms[platformKey]; + if (!rooms || rooms.length === 0) return null; + return ( + ({ key: room.live_id, @@ -545,13 +582,14 @@ const DanmakuSettings: React.FC = () => { label={room.host_name || '房间弹幕'} isRoom showBilibiliContent={platformKey === 'bilibili'} + showDouyuContent={platformKey === 'douyu'} /> ), }))} /> - ) - ))} + ); + })} ); }; From 2381f395ecd1e616b4699b16f57de15bad5f6d77 Mon Sep 17 00:00:00 2001 From: Joftal <42807405+Joftal@users.noreply.github.com> Date: Tue, 26 May 2026 14:26:21 +0800 Subject: [PATCH 02/21] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8D=E6=8A=96?= =?UTF-8?q?=E9=9F=B3=E5=BC=B9=E5=B9=95=EF=BC=8C=E8=A1=A5=E5=85=A8mock=20si?= =?UTF-8?q?gn.js=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/recorders/danmaku/douyin/client.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/recorders/danmaku/douyin/client.go b/src/recorders/danmaku/douyin/client.go index 5d3f0f7..0ee52a9 100644 --- a/src/recorders/danmaku/douyin/client.go +++ b/src/recorders/danmaku/douyin/client.go @@ -511,6 +511,20 @@ func generateSignature(wssURL string, logger *logrus.Entry) (string, error) { } vm := goja.New() + // sign.js 内部用无 var 的赋值(如 document = {})声明全局对象, + // 在 goja 中会因变量未声明报错。通过 this 赋值注入全局属性, + // 确保预编译的 RunProgram 能正确访问。 + vm.Set("window", vm.GlobalObject()) + vm.Set("document", vm.NewObject()) + nav := vm.NewObject() + nav.Set("userAgent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + vm.Set("navigator", nav) + for _, g := range []string{"location", "screen", "history", "localStorage", "sessionStorage", "crypto", "performance"} { + vm.Set(g, vm.NewObject()) + } + for _, g := range []string{"Image", "WebSocket", "XMLHttpRequest", "fetch", "setTimeout", "setInterval", "clearTimeout", "clearInterval"} { + vm.Set(g, vm.NewObject()) + } if _, err := vm.RunProgram(program); err != nil { return "", fmt.Errorf("执行 sign.js 失败: %w", err) } From 7fe3dae3afc1a9be99a60bd350a327a8ad1e58bd Mon Sep 17 00:00:00 2001 From: Joftal <42807405+Joftal@users.noreply.github.com> Date: Tue, 26 May 2026 14:35:15 +0800 Subject: [PATCH 03/21] =?UTF-8?q?feat=EF=BC=9A=E6=A0=B9=E6=8D=AE=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E7=A7=BB=E9=99=A4=E4=B8=8D=E9=80=82=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E5=BC=B9=E5=B9=95=E9=85=8D=E7=BD=AE=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cmd/bililive/bililive.go | 12 ++++++-- src/configs/config.go | 28 +++++++++++++++++-- src/servers/handler.go | 12 ++++++++ .../src/component/danmaku-config/index.tsx | 21 ++++++++++++-- 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/cmd/bililive/bililive.go b/src/cmd/bililive/bililive.go index cbb7d8d..37ba5a4 100644 --- a/src/cmd/bililive/bililive.go +++ b/src/cmd/bililive/bililive.go @@ -60,10 +60,18 @@ func getConfig() (*configs.Config, error) { // if config is invalid, try using the config.yml file besides the executable file. config, err := getConfigBesidesExecutable() if err == nil { - return config, config.Verify() + if vErr := config.Verify(); vErr != nil { + return config, vErr + } + config.StripAllIrrelevantDanmakuFields() + return config, nil } } - return config, config.Verify() + if vErr := config.Verify(); vErr != nil { + return config, vErr + } + config.StripAllIrrelevantDanmakuFields() + return config, nil } func getConfigBesidesExecutable() (*configs.Config, error) { diff --git a/src/configs/config.go b/src/configs/config.go index e0ad46f..9f7ae1e 100644 --- a/src/configs/config.go +++ b/src/configs/config.go @@ -88,8 +88,8 @@ type DanmakuConfig struct { RecordDouyuGift *bool `yaml:"record_douyu_gift,omitempty" json:"record_douyu_gift,omitempty"` // 是否录制礼物(斗鱼) RecordGuard *bool `yaml:"record_guard,omitempty" json:"record_guard,omitempty"` // 是否录制上舰 RecordSuperChat *bool `yaml:"record_super_chat,omitempty" json:"record_super_chat,omitempty"` // 是否录制SC - GuardPosition string `yaml:"guard_position" json:"guard_position"` // 上舰位置: bottom-left, bottom-right, top-left, top-right - ScPosition string `yaml:"sc_position" json:"sc_position"` // SC位置: bottom-left, bottom-right, top-left, top-right + GuardPosition string `yaml:"guard_position,omitempty" json:"guard_position"` // 上舰位置: bottom-left, bottom-right, top-left, top-right + ScPosition string `yaml:"sc_position,omitempty" json:"sc_position"` // SC位置: bottom-left, bottom-right, top-left, top-right } func BoolPtr(b bool) *bool { return &b } @@ -264,6 +264,30 @@ func GetDefaultDanmakuConfig() DanmakuConfig { return defaultDanmakuConfig } +// StripIrrelevantDanmakuFields 根据平台移除不适用的弹幕配置字段,避免 config 中存储无用数据。 +func StripIrrelevantDanmakuFields(d *DanmakuConfig, platformKey string) { + if platformKey != "bilibili" { + d.RecordGift = nil + d.RecordGuard = nil + d.RecordSuperChat = nil + d.GuardPosition = "" + d.ScPosition = "" + } + if platformKey != "douyu" { + d.RecordDouyuGift = nil + } +} + +// StripAllIrrelevantDanmakuFields 对所有房间执行平台字段清理。 +func (c *Config) StripAllIrrelevantDanmakuFields() { + for i := range c.LiveRooms { + if c.LiveRooms[i].Danmaku != nil { + platformKey := GetPlatformKeyFromUrl(c.LiveRooms[i].Url) + StripIrrelevantDanmakuFields(c.LiveRooms[i].Danmaku, platformKey) + } + } +} + // VideoSplitStrategies info. type VideoSplitStrategies struct { OnRoomNameChanged bool `yaml:"on_room_name_changed" json:"on_room_name_changed"` diff --git a/src/servers/handler.go b/src/servers/handler.go index 8cbaccb..20fdc24 100644 --- a/src/servers/handler.go +++ b/src/servers/handler.go @@ -1847,6 +1847,12 @@ func updateRoomConfigById(writer http.ResponseWriter, r *http.Request) { } } + // 移除平台不适用的弹幕字段(必须在 Validate 之后,因为 Validate 内部调用 SetDefaults 会恢复 nil 字段) + if room.Danmaku != nil { + platformKey := configs.GetPlatformKeyFromUrl(room.Url) + configs.StripIrrelevantDanmakuFields(room.Danmaku, platformKey) + } + return nil }, 3, 10*time.Millisecond) @@ -2087,6 +2093,12 @@ func updateRoomConfig(writer http.ResponseWriter, r *http.Request) { } } + // 移除平台不适用的弹幕字段(必须在 Validate 之后) + if room.Danmaku != nil { + platformKey := configs.GetPlatformKeyFromUrl(decodedUrl) + configs.StripIrrelevantDanmakuFields(room.Danmaku, platformKey) + } + return nil }, 3, 10*time.Millisecond) diff --git a/src/webapp/src/component/danmaku-config/index.tsx b/src/webapp/src/component/danmaku-config/index.tsx index e0a2cbd..c66f50e 100644 --- a/src/webapp/src/component/danmaku-config/index.tsx +++ b/src/webapp/src/component/danmaku-config/index.tsx @@ -423,12 +423,27 @@ const DanmakuSettings: React.FC = () => { } }; - const handleSaveRoom = async (liveId: string, values: any) => { + const BILIBILI_DANMAKU_FIELDS = ['record_gift', 'record_guard', 'record_super_chat', 'guard_position', 'sc_position']; + const DOUYU_DANMAKU_FIELDS = ['record_douyu_gift']; + + const filterDanmakuByPlatform = (danmaku: Record, platformKey: string): Record => { + if (!danmaku) return danmaku; + const result = { ...danmaku }; + if (platformKey !== 'bilibili') { + for (const f of BILIBILI_DANMAKU_FIELDS) delete result[f]; + } + if (platformKey !== 'douyu') { + for (const f of DOUYU_DANMAKU_FIELDS) delete result[f]; + } + return result; + }; + + const handleSaveRoom = async (liveId: string, values: any, platformKey: string) => { setSaving(true); try { await api.updateRoomConfigById(liveId, { danmaku_enable: values.danmaku_enable, - danmaku: values.danmaku, + danmaku: filterDanmakuByPlatform(values.danmaku, platformKey), }); await loadData(); } finally { @@ -576,7 +591,7 @@ const DanmakuSettings: React.FC = () => { globalDefaults={config?.danmaku} danmakuEnable={room.room_config?.danmaku_enable ?? config?.danmaku_enable} showEnable - onSave={(values) => handleSaveRoom(room.live_id, values)} + onSave={(values) => handleSaveRoom(room.live_id, values, platformKey)} onReset={() => handleResetRoom(room.live_id)} loading={saving} label={room.host_name || '房间弹幕'} From bd4e4054ebe95273979780a0d004ce53c6ce1f76 Mon Sep 17 00:00:00 2001 From: Joftal <42807405+Joftal@users.noreply.github.com> Date: Tue, 26 May 2026 15:19:12 +0800 Subject: [PATCH 04/21] =?UTF-8?q?fix=EF=BC=9A=E4=BC=98=E5=8C=96=E5=BC=B9?= =?UTF-8?q?=E5=B9=95=E8=BF=AD=E4=BB=A3=E6=9D=82=E9=A1=B9=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/configs/config.go | 64 ++++++++++++++++++--------- src/recorders/danmaku/douyu/client.go | 57 ++++++++++++++---------- src/recorders/recorder.go | 7 +++ src/servers/handler.go | 28 ++++-------- 4 files changed, 93 insertions(+), 63 deletions(-) diff --git a/src/configs/config.go b/src/configs/config.go index 9f7ae1e..35de633 100644 --- a/src/configs/config.go +++ b/src/configs/config.go @@ -135,6 +135,11 @@ var validMessagePositions = map[string]bool{ // SetDefaults 将空字段设为默认值(应在 Validate 之前调用) func (d *DanmakuConfig) SetDefaults() { + d.SetDefaultsWithPlatform("") +} + +// SetDefaultsWithPlatform 将空字段设为默认值,platformKey 用于跳过不适用平台的字段。 +func (d *DanmakuConfig) SetDefaultsWithPlatform(platformKey string) { if d.FontSize == 0 { d.FontSize = defaultDanmakuConfig.FontSize } @@ -156,29 +161,48 @@ func (d *DanmakuConfig) SetDefaults() { if d.Opacity == 0 { d.Opacity = defaultDanmakuConfig.Opacity } - if d.RecordGift == nil { - d.RecordGift = BoolPtr(true) + // Bilibili 专属字段 + if platformKey == "" || platformKey == "bilibili" { + if d.RecordGift == nil { + d.RecordGift = BoolPtr(true) + } + if d.RecordGuard == nil { + d.RecordGuard = BoolPtr(true) + } + if d.RecordSuperChat == nil { + d.RecordSuperChat = BoolPtr(true) + } + if d.GuardPosition == "" { + d.GuardPosition = "bottom-left" + } + if d.ScPosition == "" { + d.ScPosition = "bottom-left" + } + } else { + d.RecordGift = nil + d.RecordGuard = nil + d.RecordSuperChat = nil + d.GuardPosition = "" + d.ScPosition = "" } - if d.RecordDouyuGift == nil { - d.RecordDouyuGift = BoolPtr(true) - } - if d.RecordGuard == nil { - d.RecordGuard = BoolPtr(true) - } - if d.RecordSuperChat == nil { - d.RecordSuperChat = BoolPtr(true) - } - if d.GuardPosition == "" { - d.GuardPosition = "bottom-left" - } - if d.ScPosition == "" { - d.ScPosition = "bottom-left" + // 斗鱼专属字段 + if platformKey == "" || platformKey == "douyu" { + if d.RecordDouyuGift == nil { + d.RecordDouyuGift = BoolPtr(true) + } + } else { + d.RecordDouyuGift = nil } } // Validate 验证弹幕配置参数的有效性 func (d *DanmakuConfig) Validate() error { - d.SetDefaults() + return d.ValidateWithPlatform("") +} + +// ValidateWithPlatform 验证弹幕配置参数的有效性,同时根据平台清理不适用的字段。 +func (d *DanmakuConfig) ValidateWithPlatform(platformKey string) error { + d.SetDefaultsWithPlatform(platformKey) if d.FontSize < 12 || d.FontSize > 120 { return fmt.Errorf("字体大小必须在 12~120 之间,当前值: %d", d.FontSize) } @@ -200,10 +224,10 @@ func (d *DanmakuConfig) Validate() error { if d.Opacity < 0 || d.Opacity > 255 { return fmt.Errorf("背景透明度必须在 0~255 之间,当前值: %d", d.Opacity) } - if !validMessagePositions[d.GuardPosition] { + if d.GuardPosition != "" && !validMessagePositions[d.GuardPosition] { return fmt.Errorf("不支持的上舰消息位置: %s,可选值: bottom-left, bottom-right, top-left, top-right", d.GuardPosition) } - if !validMessagePositions[d.ScPosition] { + if d.ScPosition != "" && !validMessagePositions[d.ScPosition] { return fmt.Errorf("不支持的SC消息位置: %s,可选值: bottom-left, bottom-right, top-left, top-right", d.ScPosition) } return nil @@ -1365,7 +1389,7 @@ func (c *Config) ValidatePlatformConfigs() error { // 验证弹幕配置(如果指定) if platformConfig.Danmaku != nil { - if err := platformConfig.Danmaku.Validate(); err != nil { + if err := platformConfig.Danmaku.ValidateWithPlatform(platformKey); err != nil { return fmt.Errorf("平台 '%s': 弹幕配置无效: %w", platformKey, err) } } diff --git a/src/recorders/danmaku/douyu/client.go b/src/recorders/danmaku/douyu/client.go index d11c9dc..fadbe06 100644 --- a/src/recorders/danmaku/douyu/client.go +++ b/src/recorders/danmaku/douyu/client.go @@ -166,6 +166,17 @@ func (c *DouyuClient) invalidateAddrCache() { c.cachedAddr = "" } +// getConn 安全获取当前连接引用,不持锁执行 I/O。 +// 当客户端已停止时返回 nil,避免读取已关闭的连接触发无意义的重连。 +func (c *DouyuClient) getConn() net.Conn { + c.mu.Lock() + defer c.mu.Unlock() + if !c.running { + return nil + } + return c.conn +} + func (c *DouyuClient) readLoopWithReconnect(ctx context.Context) { defer func() { c.mu.Lock() @@ -262,14 +273,12 @@ func (c *DouyuClient) readLoop(ctx context.Context) error { default: } - c.mu.Lock() - if !c.running || c.conn == nil { - c.mu.Unlock() + conn := c.getConn() + if conn == nil { return nil } - c.conn.SetReadDeadline(time.Now().Add(readTimeout)) - frameType, body, err := c.readFrameLocked() - c.mu.Unlock() + conn.SetReadDeadline(time.Now().Add(readTimeout)) + frameType, body, err := c.readFrame(conn) if err != nil { return fmt.Errorf("读取帧失败: %w", err) @@ -340,7 +349,7 @@ func (c *DouyuClient) sendKeepAlive() { if !c.running || c.conn == nil { return } - c.sendFrameLocked(encodeSTT("type", "keeplive", "tick", fmt.Sprintf("%d", time.Now().UnixMilli()))) + c.sendFrame(encodeSTT("type", "keeplive", "tick", fmt.Sprintf("%d", time.Now().UnixMilli()))) } func (c *DouyuClient) sendLogin() error { @@ -349,7 +358,7 @@ func (c *DouyuClient) sendLogin() error { if !c.running || c.conn == nil { return fmt.Errorf("client not running") } - return c.sendFrameLocked(encodeSTT("type", "loginreq", "roomid", c.roomID)) + return c.sendFrame(encodeSTT("type", "loginreq", "roomid", c.roomID)) } func (c *DouyuClient) sendJoinGroup() error { @@ -358,31 +367,30 @@ func (c *DouyuClient) sendJoinGroup() error { if !c.running || c.conn == nil { return fmt.Errorf("client not running") } - return c.sendFrameLocked(encodeSTT("type", "joingroup", "rid", c.roomID, "gid", "-9999")) + return c.sendFrame(encodeSTT("type", "joingroup", "rid", c.roomID, "gid", "-9999")) } -func (c *DouyuClient) sendFrameLocked(data []byte) error { +func (c *DouyuClient) sendFrame(data []byte) error { c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) _, err := c.conn.Write(data) return err } func (c *DouyuClient) readOneFrame() ([]byte, error) { - c.mu.Lock() - defer c.mu.Unlock() - if !c.running || c.conn == nil { + conn := c.getConn() + if conn == nil { return nil, fmt.Errorf("client not running") } - c.conn.SetReadDeadline(time.Now().Add(readTimeout)) - _, body, err := c.readFrameLocked() + conn.SetReadDeadline(time.Now().Add(readTimeout)) + _, body, err := c.readFrame(conn) return body, err } -// readFrameLocked reads a single Douyu frame from c.conn. -// Caller must hold c.mu and have set the read deadline. -func (c *DouyuClient) readFrameLocked() (int, []byte, error) { +// readFrame reads a single Douyu frame from conn. +// The caller must have set the read deadline on conn. +func (c *DouyuClient) readFrame(conn net.Conn) (int, []byte, error) { header := make([]byte, 12) - if _, err := c.readFullLocked(header); err != nil { + if _, err := c.readFull(conn, header); err != nil { return 0, nil, err } length := binary.LittleEndian.Uint32(header[0:4]) @@ -392,7 +400,7 @@ func (c *DouyuClient) readFrameLocked() (int, []byte, error) { return msgType, nil, fmt.Errorf("invalid body length: %d", bodyLen) } body := make([]byte, bodyLen) - if _, err := c.readFullLocked(body); err != nil { + if _, err := c.readFull(conn, body); err != nil { return msgType, nil, err } if len(body) > 0 && body[len(body)-1] == 0 { @@ -401,12 +409,11 @@ func (c *DouyuClient) readFrameLocked() (int, []byte, error) { return msgType, body, nil } -// readFullLocked reads exactly len(buf) bytes from c.conn. -// Caller must hold c.mu. -func (c *DouyuClient) readFullLocked(buf []byte) (int, error) { +// readFull reads exactly len(buf) bytes from conn. +func (c *DouyuClient) readFull(conn net.Conn, buf []byte) (int, error) { total := 0 for total < len(buf) { - n, err := c.conn.Read(buf[total:]) + n, err := conn.Read(buf[total:]) total += n if err != nil { return total, err @@ -473,8 +480,10 @@ func escapeSTTValue(s string) string { } func unescapeSTTValue(s string) string { + s = strings.ReplaceAll(s, "@@", "\x00") // 先处理 @@,避免 @A 被错误匹配 s = strings.ReplaceAll(s, "@A", "@") s = strings.ReplaceAll(s, "@S", "/") + s = strings.ReplaceAll(s, "\x00", "@") return s } diff --git a/src/recorders/recorder.go b/src/recorders/recorder.go index 090f06b..8ebf9bd 100644 --- a/src/recorders/recorder.go +++ b/src/recorders/recorder.go @@ -212,6 +212,13 @@ type danmakuRecorder interface { GetStatus() map[string]interface{} } +// 编译期接口断言 +var ( + _ danmakuRecorder = (*danmaku.DanmakuRecorder)(nil) + _ danmakuRecorder = (*danmaku.DouyinDanmakuRecorder)(nil) + _ danmakuRecorder = (*danmaku.DouyuDanmakuRecorder)(nil) +) + type recorder struct { Live live.Live ed events.Dispatcher diff --git a/src/servers/handler.go b/src/servers/handler.go index 20fdc24..7808002 100644 --- a/src/servers/handler.go +++ b/src/servers/handler.go @@ -1722,7 +1722,7 @@ func updatePlatformConfig(writer http.ResponseWriter, r *http.Request) { // 验证弹幕配置有效性 if pc.Danmaku != nil { - if err := pc.Danmaku.Validate(); err != nil { + if err := pc.Danmaku.ValidateWithPlatform(platformKey); err != nil { return fmt.Errorf("弹幕参数无效: %w", err) } } @@ -1840,17 +1840,12 @@ func updateRoomConfigById(writer http.ResponseWriter, r *http.Request) { // 更新可覆盖配置 applyOverridableConfigUpdates(&room.OverridableConfig, updates) - // 验证弹幕配置有效性 - if room.Danmaku != nil { - if err := room.Danmaku.Validate(); err != nil { - return fmt.Errorf("弹幕参数无效: %w", err) - } - } - - // 移除平台不适用的弹幕字段(必须在 Validate 之后,因为 Validate 内部调用 SetDefaults 会恢复 nil 字段) + // 验证弹幕配置有效性(同时根据平台清理不适用的字段) if room.Danmaku != nil { platformKey := configs.GetPlatformKeyFromUrl(room.Url) - configs.StripIrrelevantDanmakuFields(room.Danmaku, platformKey) + if err := room.Danmaku.ValidateWithPlatform(platformKey); err != nil { + return fmt.Errorf("弹幕参数无效: %w", err) + } } return nil @@ -2086,17 +2081,12 @@ func updateRoomConfig(writer http.ResponseWriter, r *http.Request) { // 处理可覆盖配置(弹幕、interval、outPutPath、ffmpegPath 等) applyOverridableConfigUpdates(&room.OverridableConfig, updates) - // 验证弹幕配置 - if room.Danmaku != nil { - if err := room.Danmaku.Validate(); err != nil { - return fmt.Errorf("弹幕配置无效: %w", err) - } - } - - // 移除平台不适用的弹幕字段(必须在 Validate 之后) + // 验证弹幕配置(同时根据平台清理不适用的字段) if room.Danmaku != nil { platformKey := configs.GetPlatformKeyFromUrl(decodedUrl) - configs.StripIrrelevantDanmakuFields(room.Danmaku, platformKey) + if err := room.Danmaku.ValidateWithPlatform(platformKey); err != nil { + return fmt.Errorf("弹幕配置无效: %w", err) + } } return nil From b3b224da56bd3717a9168ae6f73fbbe795a895fa Mon Sep 17 00:00:00 2001 From: Joftal <42807405+Joftal@users.noreply.github.com> Date: Tue, 26 May 2026 16:05:31 +0800 Subject: [PATCH 05/21] =?UTF-8?q?feat=EF=BC=9A=E9=99=8D=E4=BD=8E=E6=96=97?= =?UTF-8?q?=E9=B1=BC=E5=BC=B9=E5=B9=95=E6=B8=B2=E6=9F=93=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/recorders/danmaku/douyu/client.go | 31 ++++++++++++++------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/recorders/danmaku/douyu/client.go b/src/recorders/danmaku/douyu/client.go index fadbe06..15d3829 100644 --- a/src/recorders/danmaku/douyu/client.go +++ b/src/recorders/danmaku/douyu/client.go @@ -487,23 +487,24 @@ func unescapeSTTValue(s string) string { return s } -// parseDouyuColor maps Douyu color index to ASS RGB color (BBGGRR format). -// Douyu uses small integers as color indices, not direct RGB values. +// parseDouyuColor maps Douyu color index to RGB color. +// Douyu uses small integers as color indices. Values are the softer colors +// actually used by the Douyu client, not fully saturated primaries. func parseDouyuColor(col string) int { switch col { - case "1": // red (RGB #FF0000 → BBGGRR 0x0000FF) - return 0x0000FF - case "2": // orange (RGB #FF8C00 → BBGGRR 0x008CFF) - return 0x008CFF - case "3": // green (RGB #00FF00 → BBGGRR 0x00FF00) - return 0x00FF00 - case "4": // yellow (RGB #FFFF00 → BBGGRR 0x00FFFF) - return 0x00FFFF - case "5": // purple (RGB #FF00FF → BBGGRR 0xFF00FF) - return 0xFF00FF - case "6": // cyan (RGB #00FFFF → BBGGRR 0xFFFF00) - return 0xFFFF00 - default: // white (RGB #FFFFFF → BBGGRR 0xFFFFFF) + case "1": // red #FF6B6B + return 0xFF6B6B + case "2": // orange #FFA940 + return 0xFFA940 + case "3": // green #6BD66B + return 0x6BD66B + case "4": // yellow #FFD666 + return 0xFFD666 + case "5": // purple #CC77FF + return 0xCC77FF + case "6": // cyan #66DFFF + return 0x66DFFF + default: // white #FFFFFF return 16777215 } } From 374c86a8e92f38815a1cbd6b7cb0fc25f26f831c Mon Sep 17 00:00:00 2001 From: Joftal <42807405+Joftal@users.noreply.github.com> Date: Tue, 26 May 2026 18:02:59 +0800 Subject: [PATCH 06/21] =?UTF-8?q?feat=EF=BC=9A=E6=94=AF=E6=8C=81=E6=8A=96?= =?UTF-8?q?=E9=9F=B3=E7=A4=BC=E7=89=A9=E5=BD=95=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/configs/config.go | 20 ++- src/recorders/danmaku/danmaku.go | 13 +- src/recorders/danmaku/douyin/client.go | 50 ++++++- src/recorders/danmaku/douyin/dy.pb.go | 128 ++++++++++++++++++ src/recorders/danmaku/douyin_recorder.go | 44 ++++-- src/servers/handler.go | 10 ++ .../src/component/danmaku-config/index.tsx | 32 ++++- 7 files changed, 272 insertions(+), 25 deletions(-) diff --git a/src/configs/config.go b/src/configs/config.go index 35de633..705ea79 100644 --- a/src/configs/config.go +++ b/src/configs/config.go @@ -86,6 +86,7 @@ type DanmakuConfig struct { Opacity int `yaml:"opacity" json:"opacity"` // 背景透明度 (0~255) RecordGift *bool `yaml:"record_gift,omitempty" json:"record_gift,omitempty"` // 是否录制礼物(哔哩哔哩) RecordDouyuGift *bool `yaml:"record_douyu_gift,omitempty" json:"record_douyu_gift,omitempty"` // 是否录制礼物(斗鱼) + RecordDouyinGift *bool `yaml:"record_douyin_gift,omitempty" json:"record_douyin_gift,omitempty"` // 是否录制礼物(抖音) RecordGuard *bool `yaml:"record_guard,omitempty" json:"record_guard,omitempty"` // 是否录制上舰 RecordSuperChat *bool `yaml:"record_super_chat,omitempty" json:"record_super_chat,omitempty"` // 是否录制SC GuardPosition string `yaml:"guard_position,omitempty" json:"guard_position"` // 上舰位置: bottom-left, bottom-right, top-left, top-right @@ -102,8 +103,9 @@ var defaultDanmakuConfig = DanmakuConfig{ Resolution: "1920x1080", Outline: 1, Opacity: 128, - RecordGift: BoolPtr(true), - RecordDouyuGift: BoolPtr(true), + RecordGift: BoolPtr(true), + RecordDouyuGift: BoolPtr(true), + RecordDouyinGift: BoolPtr(true), RecordGuard: BoolPtr(true), RecordSuperChat: BoolPtr(true), GuardPosition: "bottom-left", @@ -193,6 +195,14 @@ func (d *DanmakuConfig) SetDefaultsWithPlatform(platformKey string) { } else { d.RecordDouyuGift = nil } + // 抖音专属字段 + if platformKey == "" || platformKey == "douyin" { + if d.RecordDouyinGift == nil { + d.RecordDouyinGift = BoolPtr(true) + } + } else { + d.RecordDouyinGift = nil + } } // Validate 验证弹幕配置参数的有效性 @@ -267,6 +277,9 @@ func mergeDanmakuConfig(base, override *DanmakuConfig) DanmakuConfig { if override.RecordDouyuGift != nil { result.RecordDouyuGift = override.RecordDouyuGift } + if override.RecordDouyinGift != nil { + result.RecordDouyinGift = override.RecordDouyinGift + } if override.RecordGuard != nil { result.RecordGuard = override.RecordGuard } @@ -300,6 +313,9 @@ func StripIrrelevantDanmakuFields(d *DanmakuConfig, platformKey string) { if platformKey != "douyu" { d.RecordDouyuGift = nil } + if platformKey != "douyin" { + d.RecordDouyinGift = nil + } } // StripAllIrrelevantDanmakuFields 对所有房间执行平台字段清理。 diff --git a/src/recorders/danmaku/danmaku.go b/src/recorders/danmaku/danmaku.go index 1fdc09e..1941008 100644 --- a/src/recorders/danmaku/danmaku.go +++ b/src/recorders/danmaku/danmaku.go @@ -58,8 +58,6 @@ func (d *DanmakuRecorder) Start(ctx context.Context) error { c.OnDanmaku(func(msg *message.Danmaku) { d.mu.Lock() w := d.assWriter - d.count++ - d.mu.Unlock() if w != nil { w.AddDanmaku( time.Now(), @@ -68,6 +66,8 @@ func (d *DanmakuRecorder) Start(ctx context.Context) error { msg.Extra.Color, ) } + d.count++ + d.mu.Unlock() }) if d.cfg.RecordGift != nil && *d.cfg.RecordGift { @@ -75,10 +75,11 @@ func (d *DanmakuRecorder) Start(ctx context.Context) error { if msg.Num > 0 { d.mu.Lock() w := d.assWriter - d.mu.Unlock() if w != nil { w.AddGift(time.Now(), msg.Uname, msg.GiftName, msg.Num) } + d.count++ + d.mu.Unlock() } }) } @@ -87,10 +88,11 @@ func (d *DanmakuRecorder) Start(ctx context.Context) error { c.OnGuardBuy(func(msg *message.GuardBuy) { d.mu.Lock() w := d.assWriter - d.mu.Unlock() if w != nil { w.AddGuard(time.Now(), msg.Username, msg.GiftName) } + d.count++ + d.mu.Unlock() }) } @@ -98,10 +100,11 @@ func (d *DanmakuRecorder) Start(ctx context.Context) error { c.OnSuperChat(func(msg *message.SuperChat) { d.mu.Lock() w := d.assWriter - d.mu.Unlock() if w != nil { w.AddSuperChat(time.Now(), msg.UserInfo.Uname, msg.Message, msg.Price) } + d.count++ + d.mu.Unlock() }) } diff --git a/src/recorders/danmaku/douyin/client.go b/src/recorders/danmaku/douyin/client.go index 0ee52a9..5293119 100644 --- a/src/recorders/danmaku/douyin/client.go +++ b/src/recorders/danmaku/douyin/client.go @@ -44,6 +44,7 @@ type DouyinClient struct { cookies string conn *websocket.Conn onDanmaku func(username, content string) + onGift func(username, giftName string, num int) done chan struct{} logger *logrus.Entry mu sync.Mutex @@ -52,11 +53,12 @@ type DouyinClient struct { } // NewDouyinClient 创建新的抖音弹幕客户端 -func NewDouyinClient(roomID, cookies string, onDanmaku func(username, content string), logger *logrus.Entry) *DouyinClient { +func NewDouyinClient(roomID, cookies string, onDanmaku func(username, content string), onGift func(username, giftName string, num int), logger *logrus.Entry) *DouyinClient { return &DouyinClient{ roomID: roomID, cookies: cookies, onDanmaku: onDanmaku, + onGift: onGift, done: make(chan struct{}), logger: logger, } @@ -303,12 +305,17 @@ func (c *DouyinClient) readLoop(ctx context.Context) error { // handleMessage 处理单条消息 func (c *DouyinClient) handleMessage(msg *Message) { - if msg.Method != "WebcastChatMessage" { - return + switch msg.Method { + case "WebcastChatMessage": + c.handleChatMessage(msg.Payload) + case "WebcastGiftMessage": + c.handleGiftMessage(msg.Payload) } +} +func (c *DouyinClient) handleChatMessage(payload []byte) { chatMsg := &ChatMessage{} - if err := chatMsg.Unmarshal(msg.Payload); err != nil { + if err := chatMsg.Unmarshal(payload); err != nil { c.logger.WithError(err).Debug("解析 ChatMessage 失败") return } @@ -329,6 +336,41 @@ func (c *DouyinClient) handleMessage(msg *Message) { } } +func (c *DouyinClient) handleGiftMessage(payload []byte) { + giftMsg := &GiftMessage{} + if err := giftMsg.Unmarshal(payload); err != nil { + c.logger.WithError(err).Debug("解析 GiftMessage 失败") + return + } + + if c.onGift == nil { + return + } + + username := "" + if giftMsg.User != nil { + username = giftMsg.User.Nickname + } + if username == "" { + username = "未知用户" + } + + giftName := "" + if giftMsg.Gift != nil { + giftName = giftMsg.Gift.Name + } + if giftName == "" { + giftName = "礼物" + } + + num := int(giftMsg.RepeatCount) + if num < 1 { + num = 1 + } + + c.onGift(username, giftName, num) +} + // heartbeatLoop 心跳循环(每 5 秒发送一次) func (c *DouyinClient) heartbeatLoop(ctx context.Context) { ticker := time.NewTicker(5 * time.Second) diff --git a/src/recorders/danmaku/douyin/dy.pb.go b/src/recorders/danmaku/douyin/dy.pb.go index 218cabc..561cf05 100644 --- a/src/recorders/danmaku/douyin/dy.pb.go +++ b/src/recorders/danmaku/douyin/dy.pb.go @@ -537,3 +537,131 @@ func (x *User) Unmarshal(data []byte) error { } return nil } + +// GiftMessage — 礼物消息 +type GiftMessage struct { + Common *Common `json:"common"` + User *User `json:"user"` + GiftId int64 `json:"giftId"` + RepeatCount int32 `json:"repeatCount"` + ComboCount int32 `json:"comboCount"` + RepeatEnd int32 `json:"repeatEnd"` + Gift *Gift `json:"gift"` +} + +func (x *GiftMessage) Unmarshal(data []byte) error { + *x = GiftMessage{} + for len(data) > 0 { + num, typ, n := protowire.ConsumeTag(data) + if n < 0 { + return protowire.ParseError(n) + } + data = data[n:] + switch typ { + case protowire.VarintType: + v, n := protowire.ConsumeVarint(data) + if n < 0 { + return protowire.ParseError(n) + } + data = data[n:] + switch num { + case 2: + x.GiftId = int64(v) + case 5: + x.RepeatCount = int32(v) + case 6: + x.ComboCount = int32(v) + case 9: + x.RepeatEnd = int32(v) + } + case protowire.BytesType: + v, n := protowire.ConsumeBytes(data) + if n < 0 { + return protowire.ParseError(n) + } + data = data[n:] + switch num { + case 1: + msg := &Common{} + if err := msg.Unmarshal(v); err != nil { + return err + } + x.Common = msg + case 7: + msg := &User{} + if err := msg.Unmarshal(v); err != nil { + return err + } + x.User = msg + case 15: + msg := &Gift{} + if err := msg.Unmarshal(v); err != nil { + return err + } + x.Gift = msg + } + default: + n := protowire.ConsumeFieldValue(num, typ, data) + if n < 0 { + return protowire.ParseError(n) + } + data = data[n:] + } + } + return nil +} + +// Gift — 礼物详情 +type Gift struct { + Id int64 `json:"id"` + Name string `json:"name"` + DiamondCount int32 `json:"diamondCount"` + Type int32 `json:"type"` + Describe string `json:"describe"` +} + +func (x *Gift) Unmarshal(data []byte) error { + *x = Gift{} + for len(data) > 0 { + num, typ, n := protowire.ConsumeTag(data) + if n < 0 { + return protowire.ParseError(n) + } + data = data[n:] + switch typ { + case protowire.VarintType: + v, n := protowire.ConsumeVarint(data) + if n < 0 { + return protowire.ParseError(n) + } + data = data[n:] + switch num { + case 5: + x.Id = int64(v) + case 11: + x.Type = int32(v) + case 12: + x.DiamondCount = int32(v) + } + case protowire.BytesType: + v, n := protowire.ConsumeBytes(data) + if n < 0 { + return protowire.ParseError(n) + } + data = data[n:] + switch num { + case 2: + x.Describe = string(v) + case 16: + x.Name = string(v) + } + default: + n := protowire.ConsumeFieldValue(num, typ, data) + if n < 0 { + return protowire.ParseError(n) + } + data = data[n:] + } + } + return nil +} diff --git a/src/recorders/danmaku/douyin_recorder.go b/src/recorders/danmaku/douyin_recorder.go index faa3b91..f3e1b2f 100644 --- a/src/recorders/danmaku/douyin_recorder.go +++ b/src/recorders/danmaku/douyin_recorder.go @@ -24,7 +24,6 @@ type DouyinDanmakuRecorder struct { count int mu sync.Mutex running bool - done chan struct{} } // NewDouyinDanmakuRecorder 创建抖音弹幕录制器 @@ -35,7 +34,6 @@ func NewDouyinDanmakuRecorder(roomID, cookies, outputFile string, cfg configs.Da outputFile: outputFile, cfg: cfg, logger: logger, - done: make(chan struct{}), } } @@ -58,7 +56,11 @@ func (r *DouyinDanmakuRecorder) Start(ctx context.Context) error { r.assWriter = assWriter // 创建客户端 - r.client = douyin.NewDouyinClient(r.roomID, r.cookies, r.onDanmaku, r.logger) + var onGift func(username, giftName string, num int) + if r.cfg.RecordDouyinGift != nil && *r.cfg.RecordDouyinGift { + onGift = r.onGift + } + r.client = douyin.NewDouyinClient(r.roomID, r.cookies, r.onDanmaku, onGift, r.logger) // 启动客户端 if err := r.client.Start(ctx); err != nil { @@ -75,24 +77,27 @@ func (r *DouyinDanmakuRecorder) Start(ctx context.Context) error { // Stop 停止弹幕录制 func (r *DouyinDanmakuRecorder) Stop() { r.mu.Lock() - defer r.mu.Unlock() - if !r.running { + r.mu.Unlock() return } r.running = false - close(r.done) + client := r.client + writer := r.assWriter + r.client = nil + r.assWriter = nil + count := r.count + r.mu.Unlock() - if r.client != nil { - r.client.Stop() + if client != nil { + client.Stop() + } + if writer != nil { + writer.Close() } - if r.assWriter != nil { - r.assWriter.Close() - } - - r.logger.Infof("抖音弹幕录制已停止,共录制 %d 条弹幕", r.count) + r.logger.Infof("抖音弹幕录制已停止,共录制 %d 条弹幕", count) } // OutputFile 返回输出文件路径 @@ -143,3 +148,16 @@ func (r *DouyinDanmakuRecorder) onDanmaku(username, content string) { r.assWriter.AddDanmaku(time.Now(), username, content, 16777215) r.count++ } + +// onGift 礼物回调 +func (r *DouyinDanmakuRecorder) onGift(username, giftName string, num int) { + r.mu.Lock() + defer r.mu.Unlock() + + if !r.running || r.assWriter == nil { + return + } + + r.assWriter.AddGift(time.Now(), username, giftName, num) + r.count++ +} diff --git a/src/servers/handler.go b/src/servers/handler.go index 7808002..6273bbc 100644 --- a/src/servers/handler.go +++ b/src/servers/handler.go @@ -1447,6 +1447,11 @@ func applyConfigUpdates(c *configs.Config, updates map[string]interface{}) error } else if _, exists := danmaku["record_douyu_gift"]; exists && danmaku["record_douyu_gift"] == nil { c.Danmaku.RecordDouyuGift = nil } + if recordDouyinGift, ok := danmaku["record_douyin_gift"].(bool); ok { + c.Danmaku.RecordDouyinGift = configs.BoolPtr(recordDouyinGift) + } else if _, exists := danmaku["record_douyin_gift"]; exists && danmaku["record_douyin_gift"] == nil { + c.Danmaku.RecordDouyinGift = nil + } if recordGuard, ok := danmaku["record_guard"].(bool); ok { c.Danmaku.RecordGuard = configs.BoolPtr(recordGuard) } else if _, exists := danmaku["record_guard"]; exists && danmaku["record_guard"] == nil { @@ -2006,6 +2011,11 @@ func applyOverridableConfigUpdates(oc *configs.OverridableConfig, updates map[st } else if _, exists := danmaku["record_douyu_gift"]; exists && danmaku["record_douyu_gift"] == nil { oc.Danmaku.RecordDouyuGift = nil } + if recordDouyinGift, ok := danmaku["record_douyin_gift"].(bool); ok { + oc.Danmaku.RecordDouyinGift = configs.BoolPtr(recordDouyinGift) + } else if _, exists := danmaku["record_douyin_gift"]; exists && danmaku["record_douyin_gift"] == nil { + oc.Danmaku.RecordDouyinGift = nil + } if recordGuard, ok := danmaku["record_guard"].(bool); ok { oc.Danmaku.RecordGuard = configs.BoolPtr(recordGuard) } else if _, exists := danmaku["record_guard"]; exists && danmaku["record_guard"] == nil { diff --git a/src/webapp/src/component/danmaku-config/index.tsx b/src/webapp/src/component/danmaku-config/index.tsx index c66f50e..ff4c84f 100644 --- a/src/webapp/src/component/danmaku-config/index.tsx +++ b/src/webapp/src/component/danmaku-config/index.tsx @@ -17,6 +17,7 @@ const DEFAULT_DANMAKU: DanmakuConfig = { opacity: 128, record_gift: true, record_douyu_gift: true, + record_douyin_gift: true, record_guard: true, record_super_chat: true, guard_position: 'bottom-left', @@ -33,6 +34,7 @@ interface DanmakuConfig { opacity: number; record_gift: boolean; record_douyu_gift: boolean; + record_douyin_gift: boolean; record_guard: boolean; record_super_chat: boolean; guard_position: string; @@ -78,7 +80,8 @@ const DanmakuParamForm: React.FC<{ isRoom?: boolean; showBilibiliContent?: boolean; showDouyuContent?: boolean; -}> = ({ initialValues, globalDefaults, onSave, onReset, loading, showEnable, danmakuEnable, label, isRoom, showBilibiliContent, showDouyuContent }) => { + showDouyinContent?: boolean; +}> = ({ initialValues, globalDefaults, onSave, onReset, loading, showEnable, danmakuEnable, label, isRoom, showBilibiliContent, showDouyuContent, showDouyinContent }) => { const [form] = Form.useForm(); const baseDefaults = useMemo(() => globalDefaults || DEFAULT_DANMAKU, [globalDefaults]); @@ -278,6 +281,27 @@ const DanmakuParamForm: React.FC<{ }]} /> )} + + {showDouyinContent && ( + + 礼物消息 用户赠送礼物的通知} + name={['danmaku', 'record_douyin_gift']} valuePropName="checked"> + + + + ), + }]} + /> + )}