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 95d03da..f49b146 100644 --- a/src/configs/config.go +++ b/src/configs/config.go @@ -77,21 +77,24 @@ func (f *Feature) GetEffectiveDownloaderType() DownloaderType { // DanmakuConfig 弹幕录制配置 type DanmakuConfig struct { - FontSize int `yaml:"font_size" json:"font_size"` // 字体大小 (12~120) - FontName string `yaml:"font_name" json:"font_name"` // 字体名称 - ScrollArea string `yaml:"scroll_area" json:"scroll_area"` // 滚动区域: full(全屏), top(顶部), bottom(底部) - ScrollTime int `yaml:"scroll_time" json:"scroll_time"` // 弹幕滚过屏幕的秒数 (5~20) - 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"` // 是否录制礼物 + FontSize int `yaml:"font_size" json:"font_size"` // 字体大小 (12~120) + FontName string `yaml:"font_name" json:"font_name"` // 字体名称 + ScrollArea string `yaml:"scroll_area" json:"scroll_area"` // 滚动区域: full(全屏), top(顶部), bottom(底部) + ScrollTime int `yaml:"scroll_time" json:"scroll_time"` // 弹幕滚过屏幕的秒数 (5~20) + Resolution string `yaml:"resolution" json:"resolution"` // 播放分辨率 + Outline *int `yaml:"outline,omitempty" json:"outline,omitempty"` // 描边粗细 (0~4),nil 表示使用默认值 + Opacity *int `yaml:"opacity,omitempty" json:"opacity,omitempty"` // 背景透明度 (0~255),nil 表示使用默认值 + 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" 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 } +func IntPtr(i int) *int { return &i } var defaultDanmakuConfig = DanmakuConfig{ FontSize: 36, @@ -99,9 +102,11 @@ var defaultDanmakuConfig = DanmakuConfig{ ScrollArea: "full", ScrollTime: 10, Resolution: "1920x1080", - Outline: 1, - Opacity: 128, - RecordGift: BoolPtr(true), + Outline: IntPtr(1), + Opacity: IntPtr(128), + RecordGift: BoolPtr(true), + RecordDouyuGift: BoolPtr(true), + RecordDouyinGift: BoolPtr(true), RecordGuard: BoolPtr(true), RecordSuperChat: BoolPtr(true), GuardPosition: "bottom-left", @@ -133,6 +138,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 } @@ -148,32 +158,62 @@ func (d *DanmakuConfig) SetDefaults() { if d.Resolution == "" { d.Resolution = "1920x1080" } - if d.Outline == 0 { - d.Outline = defaultDanmakuConfig.Outline + if d.Outline == nil { + d.Outline = IntPtr(*defaultDanmakuConfig.Outline) } - if d.Opacity == 0 { - d.Opacity = defaultDanmakuConfig.Opacity + if d.Opacity == nil { + d.Opacity = IntPtr(*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.RecordGuard == nil { - d.RecordGuard = BoolPtr(true) + // 斗鱼专属字段 + if platformKey == "" || platformKey == "douyu" { + if d.RecordDouyuGift == nil { + d.RecordDouyuGift = BoolPtr(true) + } + } else { + d.RecordDouyuGift = nil } - 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 == "douyin" { + if d.RecordDouyinGift == nil { + d.RecordDouyinGift = BoolPtr(true) + } + } else { + d.RecordDouyinGift = 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) } @@ -189,23 +229,23 @@ func (d *DanmakuConfig) Validate() error { if !validResolutions[d.Resolution] { return fmt.Errorf("不支持的分辨率: %s,可选值: 1920x1080, 1280x720, 2560x1440, 3840x2160", d.Resolution) } - if d.Outline < 0 || d.Outline > 4 { - return fmt.Errorf("描边粗细必须在 0~4 之间,当前值: %d", d.Outline) + if *d.Outline < 0 || *d.Outline > 4 { + return fmt.Errorf("描边粗细必须在 0~4 之间,当前值: %d", *d.Outline) } - if d.Opacity < 0 || d.Opacity > 255 { - return fmt.Errorf("背景透明度必须在 0~255 之间,当前值: %d", d.Opacity) + 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 } -// mergeDanmakuConfig 合并弹幕配置,override 中的非零值覆盖 base -// *bool 字段:nil 表示继承,非 nil 表示覆盖 +// mergeDanmakuConfig 合并弹幕配置,override 中的非零/非nil值覆盖 base +// *bool/*int 字段:nil 表示继承,非 nil 表示覆盖 func mergeDanmakuConfig(base, override *DanmakuConfig) DanmakuConfig { if override == nil { return *base @@ -226,15 +266,21 @@ func mergeDanmakuConfig(base, override *DanmakuConfig) DanmakuConfig { if override.Resolution != "" { result.Resolution = override.Resolution } - if override.Outline != 0 { - result.Outline = override.Outline + if override.Outline != nil { + result.Outline = IntPtr(*override.Outline) } - if override.Opacity != 0 { - result.Opacity = override.Opacity + if override.Opacity != nil { + result.Opacity = IntPtr(*override.Opacity) } if override.RecordGift != nil { result.RecordGift = override.RecordGift } + if override.RecordDouyuGift != nil { + result.RecordDouyuGift = override.RecordDouyuGift + } + if override.RecordDouyinGift != nil { + result.RecordDouyinGift = override.RecordDouyinGift + } if override.RecordGuard != nil { result.RecordGuard = override.RecordGuard } @@ -247,7 +293,6 @@ func mergeDanmakuConfig(base, override *DanmakuConfig) DanmakuConfig { if override.ScPosition != "" { result.ScPosition = override.ScPosition } - result.SetDefaults() return result } @@ -256,6 +301,33 @@ 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 + } + if platformKey != "douyin" { + d.RecordDouyinGift = 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"` @@ -426,7 +498,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"` // 弹幕录制参数 } @@ -1333,7 +1405,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/live/douyu/douyu.go b/src/live/douyu/douyu.go index 314e3ef..c5492ba 100644 --- a/src/live/douyu/douyu.go +++ b/src/live/douyu/douyu.go @@ -145,6 +145,12 @@ type Live struct { roomID string } +// GetRoomID 返回已解析的数字房间号(由 fetchRoomID 解析)。 +// 如果 URL 本身就是数字房间号,也会在 fetchRoomID 中缓存。 +func (l *Live) GetRoomID() string { + return l.roomID +} + func (l *Live) fetchRoomID() error { if l.roomID != "" { return nil diff --git a/src/live/lives.go b/src/live/lives.go index 9454236..b3d640f 100755 --- a/src/live/lives.go +++ b/src/live/lives.go @@ -290,6 +290,18 @@ func (w *WrappedLive) Close() { } } +// GetRoomID 返回底层 Live 已解析的数字房间号(如有)。 +// 用于斗鱼等平台别名 URL 的弹幕录制。 +func (w *WrappedLive) GetRoomID() string { + type roomIDProvider interface { + GetRoomID() string + } + if provider, ok := w.Live.(roomIDProvider); ok { + return provider.GetRoomID() + } + return "" +} + func (w *WrappedLive) GetInfo() (*Info, error) { // 在通用位置应用平台访问频率限制 // 如果被取消则直接返回 diff --git a/src/recorders/danmaku/ass.go b/src/recorders/danmaku/ass.go index f80bf83..da622c4 100644 --- a/src/recorders/danmaku/ass.go +++ b/src/recorders/danmaku/ass.go @@ -16,6 +16,7 @@ type AssWriter struct { mu sync.Mutex file *os.File closed bool + writeErr bool // 首次写入出错后置 true,后续跳过无意义写入 startAt time.Time cfg configs.DanmakuConfig title string @@ -106,7 +107,7 @@ func scTierColor(price int) string { case price >= 2000: return "&H80E73CC6" // 紫色 #C678F5 case price >= 1000: - return "&H80396AFF" // 深红 #FF6A39 + return "&H8030CAFF" // 橙金 #FFCA30 case price >= 500: return "&H80396AFF" // 红色 #FF6A39 case price >= 200: @@ -149,7 +150,9 @@ func scTierStyle(price int) string { } func (w *AssWriter) writeHeader() error { - assAlpha := 255 - w.cfg.Opacity + opacity := *w.cfg.Opacity + outline := *w.cfg.Outline + assAlpha := 255 - opacity backColor := fmt.Sprintf("&H%02X000000&", assAlpha) guardBackColor := "&H800080FF" @@ -190,8 +193,8 @@ Style: SCDefault,%s,%d,&H00FFFFFF,&H000000FF,&H00000000,%s,1,0,0,0,100,100,0,0,3 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text `, w.title, w.resX, w.resY, - w.cfg.FontName, w.cfg.FontSize, backColor, w.cfg.Outline, - w.cfg.FontName, w.cfg.FontSize-6, backColor, w.cfg.Outline, + w.cfg.FontName, w.cfg.FontSize, backColor, outline, + w.cfg.FontName, w.cfg.FontSize-6, backColor, outline, w.cfg.FontName, w.cfg.FontSize, guardBackColor, w.cfg.FontName, w.cfg.FontSize, sc2, w.cfg.FontName, w.cfg.FontSize, sc30, @@ -222,7 +225,7 @@ func (w *AssWriter) estimateTextWidth(text string) int { func (w *AssWriter) AddDanmaku(recvAt time.Time, username, text string, color int) { w.mu.Lock() defer w.mu.Unlock() - if w.closed { + if w.closed || w.writeErr { return } @@ -253,14 +256,16 @@ func (w *AssWriter) AddDanmaku(recvAt time.Time, username, text string, color in line := fmt.Sprintf("Dialogue: 0,%s,%s,Danmaku,,0,0,%d,Banner;%d;0;30,{\\c%s}%s\n", formatTime(startCS), formatTime(endCS), marginV, w.bannerSpeed, assColor, escapeText(fullText)) - w.file.WriteString(line) + if _, err := w.file.WriteString(line); err != nil { + w.writeErr = true + } } // AddGift appends a gift message as a smaller scrolling line. func (w *AssWriter) AddGift(recvAt time.Time, username, giftName string, num int) { w.mu.Lock() defer w.mu.Unlock() - if w.closed { + if w.closed || w.writeErr { return } @@ -285,7 +290,9 @@ func (w *AssWriter) AddGift(recvAt time.Time, username, giftName string, num int line := fmt.Sprintf("Dialogue: 0,%s,%s,Gift,,0,0,%d,Banner;%d;0;30,%s\n", formatTime(startCS), formatTime(endCS), marginV, w.bannerSpeed, escapeText(fullText)) - w.file.WriteString(line) + if _, err := w.file.WriteString(line); err != nil { + w.writeErr = true + } } // positionToAlignment maps position string to ASS \an alignment value and margin. @@ -307,7 +314,7 @@ func positionToAlignment(pos string, bottomMargin int) (alignment int, marginV i func (w *AssWriter) AddGuard(recvAt time.Time, username, giftName string) { w.mu.Lock() defer w.mu.Unlock() - if w.closed { + if w.closed || w.writeErr { return } @@ -322,14 +329,16 @@ func (w *AssWriter) AddGuard(recvAt time.Time, username, giftName string) { alignment, marginV := positionToAlignment(w.cfg.GuardPosition, 60) line := fmt.Sprintf("Dialogue: 1,%s,%s,Guard,,0,0,%d,,{\\an%d}{\\q0}%s\n", formatTime(startCS), formatTime(endCS), marginV, alignment, escapeText(fullText)) - w.file.WriteString(line) + if _, err := w.file.WriteString(line); err != nil { + w.writeErr = true + } } // AddSuperChat appends a Super Chat message. func (w *AssWriter) AddSuperChat(recvAt time.Time, username, text string, price int) { w.mu.Lock() defer w.mu.Unlock() - if w.closed { + if w.closed || w.writeErr { return } @@ -345,7 +354,9 @@ func (w *AssWriter) AddSuperChat(recvAt time.Time, username, text string, price styleName := scTierStyle(price) line := fmt.Sprintf("Dialogue: 1,%s,%s,%s,,0,0,%d,,{\\an%d}{\\q0}%s\n", formatTime(startCS), formatTime(endCS), styleName, marginV, alignment, escapeText(fullText)) - w.file.WriteString(line) + if _, err := w.file.WriteString(line); err != nil { + w.writeErr = true + } } func (w *AssWriter) assignLane(startCS, endCS int64) int { @@ -401,6 +412,12 @@ func escapeText(s string) string { result := make([]byte, 0, len(s)) for i := 0; i < len(s); i++ { switch s[i] { + case '\\': + result = append(result, '\\', '\\') + case '{': + result = append(result, '\\', '{') + case '}': + result = append(result, '\\', '}') case '\n': result = append(result, '\\', 'n') case '\r': diff --git a/src/recorders/danmaku/base.go b/src/recorders/danmaku/base.go new file mode 100644 index 0000000..c488dcc --- /dev/null +++ b/src/recorders/danmaku/base.go @@ -0,0 +1,89 @@ +package danmaku + +import ( + "sync" + "time" + + "github.com/sirupsen/logrus" + + "github.com/bililive-go/bililive-go/src/configs" +) + +// baseRecorder 提供三个平台弹幕录制器的公共字段和方法。 +type baseRecorder struct { + mu sync.Mutex + running bool + count int + assWriter *AssWriter + outputFile string + cfg configs.DanmakuConfig + logger *logrus.Entry + startAt time.Time +} + +func (b *baseRecorder) OutputFile() string { + return b.outputFile +} + +func (b *baseRecorder) GetCount() int { + b.mu.Lock() + defer b.mu.Unlock() + return b.count +} + +func (b *baseRecorder) IsRunning() bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.running +} + +func (b *baseRecorder) GetStatus() map[string]interface{} { + b.mu.Lock() + defer b.mu.Unlock() + + status := map[string]interface{}{ + "danmaku_running": b.running, + "danmaku_count": b.count, + "danmaku_output": b.outputFile, + } + if b.running { + status["danmaku_start_time"] = b.startAt.Format(time.RFC3339) + } + return status +} + +// stopBase 通用停止逻辑:标记停止、清空 writer、返回旧引用供调用方关闭。 +func (b *baseRecorder) stopBase() (*AssWriter) { + b.mu.Lock() + if !b.running { + b.mu.Unlock() + return nil + } + b.running = false + w := b.assWriter + b.assWriter = nil + b.mu.Unlock() + return w +} + +// addDanmaku 弹幕回调的通用处理:加锁、检查运行状态、写入 ASS、计数。 +func (b *baseRecorder) addDanmaku(recvAt time.Time, username, content string, color int) { + b.mu.Lock() + defer b.mu.Unlock() + if !b.running || b.assWriter == nil { + return + } + b.assWriter.AddDanmaku(recvAt, username, content, color) + b.count++ +} + +// addGift 礼物回调的通用处理。 +func (b *baseRecorder) addGift(recvAt time.Time, username, giftName string, num int) { + b.mu.Lock() + defer b.mu.Unlock() + if !b.running || b.assWriter == nil { + return + } + b.assWriter.AddGift(recvAt, username, giftName, num) + b.count++ +} diff --git a/src/recorders/danmaku/danmaku.go b/src/recorders/danmaku/danmaku.go index 1fdc09e..2a74e53 100644 --- a/src/recorders/danmaku/danmaku.go +++ b/src/recorders/danmaku/danmaku.go @@ -3,7 +3,6 @@ package danmaku import ( "context" "fmt" - "sync" "time" "github.com/Akegarasu/blivedm-go/client" @@ -13,34 +12,36 @@ import ( "github.com/bililive-go/bililive-go/src/configs" ) -// DanmakuRecorder connects to a Bilibili live room via WebSocket, -// receives danmaku messages, and writes them to an ASS subtitle file. +// DanmakuRecorder 哔哩哔哩弹幕录制器 type DanmakuRecorder struct { - roomID int - cookies string - outputFile string - cfg configs.DanmakuConfig - startAt time.Time - assWriter *AssWriter - client *client.Client - logger *logrus.Entry - count int - mu sync.Mutex + baseRecorder + roomID int + cookies string + client *client.Client } -// NewDanmakuRecorder creates a new danmaku recorder. +// NewDanmakuRecorder 创建哔哩哔哩弹幕录制器 func NewDanmakuRecorder(roomID int, cookies string, outputFile string, cfg configs.DanmakuConfig, logger *logrus.Entry) *DanmakuRecorder { return &DanmakuRecorder{ - roomID: roomID, - cookies: cookies, - outputFile: outputFile, - cfg: cfg, - logger: logger, + baseRecorder: baseRecorder{ + outputFile: outputFile, + cfg: cfg, + logger: logger, + }, + roomID: roomID, + cookies: cookies, } } -// Start begins recording danmaku. +// Start 开始弹幕录制 func (d *DanmakuRecorder) Start(ctx context.Context) error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.running { + return nil + } + d.startAt = time.Now() assWriter, err := NewAssWriter(d.outputFile, d.startAt, d.cfg, "Bilibili Danmaku") @@ -56,29 +57,13 @@ func (d *DanmakuRecorder) Start(ctx context.Context) error { d.client = c c.OnDanmaku(func(msg *message.Danmaku) { - d.mu.Lock() - w := d.assWriter - d.count++ - d.mu.Unlock() - if w != nil { - w.AddDanmaku( - time.Now(), - msg.Sender.Uname, - msg.Content, - msg.Extra.Color, - ) - } + d.addDanmaku(time.Now(), msg.Sender.Uname, msg.Content, msg.Extra.Color) }) if d.cfg.RecordGift != nil && *d.cfg.RecordGift { c.OnGift(func(msg *message.Gift) { 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.addGift(time.Now(), msg.Uname, msg.GiftName, msg.Num) } }) } @@ -86,30 +71,38 @@ func (d *DanmakuRecorder) Start(ctx context.Context) error { if d.cfg.RecordGuard != nil && *d.cfg.RecordGuard { 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) + if !d.running || d.assWriter == nil { + d.mu.Unlock() + return } + d.assWriter.AddGuard(time.Now(), msg.Username, msg.GiftName) + d.count++ + d.mu.Unlock() }) } if d.cfg.RecordSuperChat != nil && *d.cfg.RecordSuperChat { 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) + if !d.running || d.assWriter == nil { + d.mu.Unlock() + return } + d.assWriter.AddSuperChat(time.Now(), msg.UserInfo.Uname, msg.Message, msg.Price) + d.count++ + d.mu.Unlock() }) } if err := c.Start(); err != nil { assWriter.Close() + d.assWriter = nil return fmt.Errorf("failed to start danmaku client: %w", err) } + d.running = true + d.logger.Info("弹幕录制已启动") + go func() { <-ctx.Done() d.Stop() @@ -118,57 +111,21 @@ func (d *DanmakuRecorder) Start(ctx context.Context) error { return nil } -// Stop stops the danmaku recorder. +// Stop 停止弹幕录制 func (d *DanmakuRecorder) Stop() { - d.mu.Lock() + w := d.stopBase() c := d.client - w := d.assWriter d.client = nil - d.assWriter = nil - d.mu.Unlock() - // 在锁外关闭,避免与回调死锁 if c != nil { c.Stop() } if w != nil { w.Close() } - d.mu.Lock() - count := d.count - d.mu.Unlock() + count := d.GetCount() if count > 0 { d.logger.Infof("弹幕录制已停止,共录制 %d 条弹幕 -> %s", count, d.outputFile) } else { d.logger.Info("弹幕录制已停止,未收到弹幕") } } - -// OutputFile returns the path of the ASS output file. -func (d *DanmakuRecorder) OutputFile() string { - return d.outputFile -} - -// GetCount returns the number of danmaku received so far. -func (d *DanmakuRecorder) GetCount() int { - d.mu.Lock() - defer d.mu.Unlock() - return d.count -} - -// IsRunning returns whether the danmaku WebSocket client is active. -func (d *DanmakuRecorder) IsRunning() bool { - d.mu.Lock() - defer d.mu.Unlock() - return d.client != nil -} - -// GetStatus returns the current danmaku recording status. -func (d *DanmakuRecorder) GetStatus() map[string]interface{} { - d.mu.Lock() - defer d.mu.Unlock() - return map[string]interface{}{ - "danmaku_running": d.client != nil, - "danmaku_count": d.count, - "danmaku_output": d.outputFile, - } -} diff --git a/src/recorders/danmaku/douyin/client.go b/src/recorders/danmaku/douyin/client.go index 5d3f0f7..06b35a0 100644 --- a/src/recorders/danmaku/douyin/client.go +++ b/src/recorders/danmaku/douyin/client.go @@ -22,6 +22,11 @@ import ( //go:embed sign.js var signJSCode string +var ( + reRoomID = regexp.MustCompile(`"room_id"\s*:\s*(\d+)`) + reRoomID2 = regexp.MustCompile(`roomId\\?":\\?"(\d+)`) +) + // signJSProgram 预编译的 sign.js 字节码,避免每次签名都重新解析 485KB 脚本 var signJSProgram *goja.Program var signJSOnce sync.Once @@ -44,7 +49,9 @@ type DouyinClient struct { cookies string conn *websocket.Conn onDanmaku func(username, content string) + onGift func(username, giftName string, num int) done chan struct{} + closeOnce sync.Once logger *logrus.Entry mu sync.Mutex running bool @@ -52,11 +59,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, } @@ -145,7 +153,7 @@ func (c *DouyinClient) Stop() { return } c.running = false - close(c.done) + c.closeOnce.Do(func() { close(c.done) }) if c.conn != nil { c.conn.Close() } @@ -187,6 +195,13 @@ func (c *DouyinClient) readLoopWithReconnect(ctx context.Context, realRoomID, tt reconnectCount++ if reconnectCount > maxReconnect { c.logger.Errorf("重连 %d 次仍然失败,停止弹幕录制", maxReconnect) + c.mu.Lock() + c.running = false + c.closeOnce.Do(func() { close(c.done) }) + if c.conn != nil { + c.conn.Close() + } + c.mu.Unlock() return } @@ -242,6 +257,17 @@ func (c *DouyinClient) readLoopWithReconnect(ctx context.Context, realRoomID, tt } } +// getConn 安全获取当前连接引用,不持锁执行 I/O。 +// 当客户端已停止时返回 nil,避免读取已关闭的连接触发无意义的重连。 +func (c *DouyinClient) getConn() *websocket.Conn { + c.mu.Lock() + defer c.mu.Unlock() + if !c.running { + return nil + } + return c.conn +} + // readLoop 消息读取循环 func (c *DouyinClient) readLoop(ctx context.Context) error { for { @@ -253,9 +279,14 @@ func (c *DouyinClient) readLoop(ctx context.Context) error { default: } - c.conn.SetReadDeadline(time.Now().Add(30 * time.Second)) + conn := c.getConn() + if conn == nil { + return nil + } - _, message, err := c.conn.ReadMessage() + conn.SetReadDeadline(time.Now().Add(30 * time.Second)) + + _, message, err := conn.ReadMessage() if err != nil { if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { c.logger.Info("WebSocket 连接正常关闭") @@ -303,12 +334,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 +365,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) @@ -346,7 +417,7 @@ func (c *DouyinClient) heartbeatLoop(ctx context.Context) { } } -// sendHeartbeat 发送心跳(PING 帧,payloadType='hb') +// sendHeartbeat 发送心跳帧(BinaryMessage,payloadType='hb') func (c *DouyinClient) sendHeartbeat() { c.mu.Lock() defer c.mu.Unlock() @@ -367,7 +438,7 @@ func (c *DouyinClient) sendHeartbeat() { } c.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) - c.conn.WriteMessage(websocket.PingMessage, data) + c.conn.WriteMessage(websocket.BinaryMessage, data) } // sendACK 发送 ACK 确认帧 @@ -444,8 +515,7 @@ func fetchRealRoomID(roomID, cookies string, logger *logrus.Entry) (string, erro } // 从页面 JSON 中提取 roomId - re := regexp.MustCompile(`"room_id"\s*:\s*(\d+)`) - matches := re.FindSubmatch(body) + matches := reRoomID.FindSubmatch(body) if len(matches) >= 2 { realID := string(matches[1]) if realID != "0" { @@ -454,8 +524,7 @@ func fetchRealRoomID(roomID, cookies string, logger *logrus.Entry) (string, erro } } - re2 := regexp.MustCompile(`roomId\\?":\\?"(\d+)`) - matches2 := re2.FindSubmatch(body) + matches2 := reRoomID2.FindSubmatch(body) if len(matches2) >= 2 { realID := string(matches2[1]) logger.Infof("从页面解析到真实 roomId: %s (web_rid: %s)", realID, roomID) @@ -511,6 +580,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", userAgent) + 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) } 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/dy.proto b/src/recorders/danmaku/douyin/dy.proto index 533ef2c..99fd54a 100644 --- a/src/recorders/danmaku/douyin/dy.proto +++ b/src/recorders/danmaku/douyin/dy.proto @@ -64,6 +64,26 @@ message Common { int64 showEffect = 6; } +// GiftMessage — 礼物消息 +message GiftMessage { + Common common = 1; + int64 giftId = 2; + int32 repeatCount = 5; + int32 comboCount = 6; + User user = 7; + int32 repeatEnd = 9; + Gift gift = 15; +} + +// Gift — 礼物详情 +message Gift { + string describe = 2; + int64 id = 5; + int32 type = 11; + int32 diamondCount = 12; + string name = 16; +} + // User — 用户信息 message User { int64 id = 1; diff --git a/src/recorders/danmaku/douyin/sign.js b/src/recorders/danmaku/douyin/sign.js index eef7918..0bc3f09 100644 --- a/src/recorders/danmaku/douyin/sign.js +++ b/src/recorders/danmaku/douyin/sign.js @@ -1,6 +1,6 @@ document = {} window = {} -navigator = {'userAgent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'} +navigator = {'userAgent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0'} var crawler; diff --git a/src/recorders/danmaku/douyin_recorder.go b/src/recorders/danmaku/douyin_recorder.go index faa3b91..6eacf73 100644 --- a/src/recorders/danmaku/douyin_recorder.go +++ b/src/recorders/danmaku/douyin_recorder.go @@ -2,7 +2,6 @@ package danmaku import ( "context" - "sync" "time" "github.com/sirupsen/logrus" @@ -13,29 +12,22 @@ import ( // DouyinDanmakuRecorder 抖音弹幕录制器 type DouyinDanmakuRecorder struct { - roomID string - cookies string - outputFile string - cfg configs.DanmakuConfig - startAt time.Time - assWriter *AssWriter - client *douyin.DouyinClient - logger *logrus.Entry - count int - mu sync.Mutex - running bool - done chan struct{} + baseRecorder + roomID string + cookies string + client *douyin.DouyinClient } // NewDouyinDanmakuRecorder 创建抖音弹幕录制器 func NewDouyinDanmakuRecorder(roomID, cookies, outputFile string, cfg configs.DanmakuConfig, logger *logrus.Entry) *DouyinDanmakuRecorder { return &DouyinDanmakuRecorder{ - roomID: roomID, - cookies: cookies, - outputFile: outputFile, - cfg: cfg, - logger: logger, - done: make(chan struct{}), + baseRecorder: baseRecorder{ + outputFile: outputFile, + cfg: cfg, + logger: logger, + }, + roomID: roomID, + cookies: cookies, } } @@ -50,17 +42,20 @@ func (r *DouyinDanmakuRecorder) Start(ctx context.Context) error { r.startAt = time.Now() - // 创建 ASS Writer assWriter, err := NewAssWriter(r.outputFile, r.startAt, r.cfg, "Douyin Danmaku") if err != nil { return err } 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 = func(username, giftName string, num int) { + r.addGift(time.Now(), username, giftName, num) + } + } + r.client = douyin.NewDouyinClient(r.roomID, r.cookies, r.onDanmaku, onGift, r.logger) - // 启动客户端 if err := r.client.Start(ctx); err != nil { assWriter.Close() return err @@ -74,72 +69,19 @@ func (r *DouyinDanmakuRecorder) Start(ctx context.Context) error { // Stop 停止弹幕录制 func (r *DouyinDanmakuRecorder) Stop() { - r.mu.Lock() - defer r.mu.Unlock() - - if !r.running { - return + w := r.stopBase() + c := r.client + r.client = nil + if c != nil { + c.Stop() } - - r.running = false - close(r.done) - - if r.client != nil { - r.client.Stop() + if w != nil { + w.Close() } - - if r.assWriter != nil { - r.assWriter.Close() - } - - r.logger.Infof("抖音弹幕录制已停止,共录制 %d 条弹幕", r.count) -} - -// OutputFile 返回输出文件路径 -func (r *DouyinDanmakuRecorder) OutputFile() string { - return r.outputFile -} - -// GetCount 返回已录制弹幕数量 -func (r *DouyinDanmakuRecorder) GetCount() int { - r.mu.Lock() - defer r.mu.Unlock() - return r.count -} - -// IsRunning 返回是否正在录制 -func (r *DouyinDanmakuRecorder) IsRunning() bool { - r.mu.Lock() - defer r.mu.Unlock() - return r.running -} - -// GetStatus 返回弹幕录制状态 -func (r *DouyinDanmakuRecorder) 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 + r.logger.Infof("抖音弹幕录制已停止,共录制 %d 条弹幕", r.GetCount()) } // onDanmaku 弹幕回调 func (r *DouyinDanmakuRecorder) onDanmaku(username, content string) { - r.mu.Lock() - defer r.mu.Unlock() - - if !r.running || r.assWriter == nil { - return - } - - // 抖音弹幕不含颜色信息,使用默认白色 (16777215) - r.assWriter.AddDanmaku(time.Now(), username, content, 16777215) - r.count++ + r.addDanmaku(time.Now(), username, content, 16777215) } diff --git a/src/recorders/danmaku/douyu/client.go b/src/recorders/danmaku/douyu/client.go new file mode 100644 index 0000000..6eb46fc --- /dev/null +++ b/src/recorders/danmaku/douyu/client.go @@ -0,0 +1,550 @@ +package douyu + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "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{} + closeOnce sync.Once + 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() + c.mu.Lock() + c.running = false + c.conn = nil + c.mu.Unlock() + return fmt.Errorf("发送登录消息失败: %w", err) + } + + resp, err := c.readOneFrame() + if err != nil { + conn.Close() + c.mu.Lock() + c.running = false + c.conn = nil + c.mu.Unlock() + return fmt.Errorf("读取登录响应失败: %w", err) + } + fields := parseSTT(resp) + if fields["type"] != "loginres" { + conn.Close() + c.mu.Lock() + c.running = false + c.conn = nil + c.mu.Unlock() + return fmt.Errorf("登录失败: %s", string(resp)) + } + c.logger.Debug("斗鱼弹幕登录成功") + + if err := c.sendJoinGroup(); err != nil { + conn.Close() + c.mu.Lock() + c.running = false + c.conn = nil + c.mu.Unlock() + 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 + c.closeOnce.Do(func() { 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 = "" +} + +// 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() + c.running = false + c.closeOnce.Do(func() { close(c.done) }) + if c.conn != nil { + c.conn.Close() + } + 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) + c.mu.Lock() + c.running = false + c.closeOnce.Do(func() { close(c.done) }) + if c.conn != nil { + c.conn.Close() + } + c.mu.Unlock() + 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("重连后登录失败") + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.mu.Unlock() + continue + } + resp, readErr := c.readOneFrame() + if readErr != nil { + c.logger.WithError(readErr).Warn("重连后读取登录响应失败") + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.mu.Unlock() + continue + } + respFields := parseSTT(resp) + if respFields["type"] != "loginres" { + c.logger.Warnf("重连后登录失败: %s", string(resp)) + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.mu.Unlock() + continue + } + if joinErr := c.sendJoinGroup(); joinErr != nil { + c.logger.WithError(joinErr).Warn("重连后加入房间失败") + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.mu.Unlock() + 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: + } + + conn := c.getConn() + if conn == nil { + return fmt.Errorf("连接不可用") + } + conn.SetReadDeadline(time.Now().Add(readTimeout)) + frameType, body, err := c.readFrame(conn) + + 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.sendFrame(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.sendFrame(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.sendFrame(encodeSTT("type", "joingroup", "rid", c.roomID, "gid", "-9999")) +} + +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) { + conn := c.getConn() + if conn == nil { + return nil, fmt.Errorf("client not running") + } + conn.SetReadDeadline(time.Now().Add(readTimeout)) + _, body, err := c.readFrame(conn) + return body, err +} + +// 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 := io.ReadFull(conn, 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 := io.ReadFull(conn, 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 +} + +// 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, "@@", "\x00") // 先处理 @@,避免 @A 被错误匹配 + s = strings.ReplaceAll(s, "@A", "@") + s = strings.ReplaceAll(s, "@S", "/") + s = strings.ReplaceAll(s, "\x00", "@") + return s +} + +// 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 #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 + } +} diff --git a/src/recorders/danmaku/douyu_recorder.go b/src/recorders/danmaku/douyu_recorder.go new file mode 100644 index 0000000..baf63d3 --- /dev/null +++ b/src/recorders/danmaku/douyu_recorder.go @@ -0,0 +1,89 @@ +package danmaku + +import ( + "context" + "time" + + "github.com/sirupsen/logrus" + + "github.com/bililive-go/bililive-go/src/configs" + "github.com/bililive-go/bililive-go/src/recorders/danmaku/douyu" +) + +// DouyuDanmakuRecorder 斗鱼弹幕录制器 +type DouyuDanmakuRecorder struct { + baseRecorder + roomID string + cookies string + client *douyu.DouyuClient +} + +// NewDouyuDanmakuRecorder 创建斗鱼弹幕录制器 +func NewDouyuDanmakuRecorder(roomID, cookies, outputFile string, cfg configs.DanmakuConfig, logger *logrus.Entry) *DouyuDanmakuRecorder { + return &DouyuDanmakuRecorder{ + baseRecorder: baseRecorder{ + outputFile: outputFile, + cfg: cfg, + logger: logger, + }, + roomID: roomID, + cookies: cookies, + } +} + +// Start 开始弹幕录制 +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 + r.startAt = startAt + + var onGift func(username, giftName string, num int) + if r.cfg.RecordDouyuGift != nil && *r.cfg.RecordDouyuGift { + onGift = func(username, giftName string, num int) { + r.addGift(time.Now(), username, giftName, num) + } + } + + 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.running = true + r.logger.Info("斗鱼弹幕录制已启动") + + return nil +} + +// Stop 停止弹幕录制 +func (r *DouyuDanmakuRecorder) Stop() { + w := r.stopBase() + c := r.client + r.client = nil + if c != nil { + c.Stop() + } + if w != nil { + w.Close() + } + r.logger.Infof("斗鱼弹幕录制已停止,共录制 %d 条弹幕", r.GetCount()) +} + +// onDanmaku 弹幕回调 +func (r *DouyuDanmakuRecorder) onDanmaku(username, content string, color int) { + r.addDanmaku(time.Now(), username, content, color) +} diff --git a/src/recorders/recorder.go b/src/recorders/recorder.go index e04e0f7..180e7d8 100644 --- a/src/recorders/recorder.go +++ b/src/recorders/recorder.go @@ -20,6 +20,7 @@ import ( "time" "github.com/bluele/gcache" + "github.com/sirupsen/logrus" "github.com/bililive-go/bililive-go/src/configs" "github.com/bililive-go/bililive-go/src/instance" @@ -212,6 +213,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 @@ -500,53 +508,24 @@ func (r *recorder) tryRecord(ctx context.Context) { r.setAndCloseParser(p) r.startTime = time.Now() - // 弹幕录制(支持哔哩哔哩和抖音平台) + // 弹幕录制(支持哔哩哔哩、抖音、斗鱼平台) if resolvedConfig.DanmakuEnable { switch r.Live.GetPlatformCNName() { case "哔哩哔哩": - assFile := fileName[:strings.LastIndex(fileName, ".")] + ".ass" - roomID := extractRoomIDFromUrl(r.Live.GetRawUrl()) - cookies := extractCookiesString(r.Live) - if roomID > 0 { - r.getLogger().Infof("弹幕录制已启用,房间ID: %d, 输出: %s", roomID, assFile) - rec := danmaku.NewDanmakuRecorder(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()) - } + r.startDanmakuRecorder(ctx, fileName, "哔哩哔哩", resolvedConfig, + func(rid, cookies, assFile string, cfg configs.DanmakuConfig, logger *logrus.Entry) danmakuRecorder { + return danmaku.NewDanmakuRecorder(extractRoomIDFromUrl(r.Live.GetRawUrl()), cookies, assFile, cfg, logger) + }) case "抖音": - assFile := fileName[:strings.LastIndex(fileName, ".")] + ".ass" - roomID := extractDouyinRoomID(r.Live) - cookies := extractCookiesString(r.Live) - if roomID != "" { - r.getLogger().Infof("弹幕录制已启用,房间ID: %s, 输出: %s", roomID, assFile) - rec := danmaku.NewDouyinDanmakuRecorder(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()) - } + r.startDanmakuRecorder(ctx, fileName, "抖音", resolvedConfig, + func(rid, cookies, assFile string, cfg configs.DanmakuConfig, logger *logrus.Entry) danmakuRecorder { + return danmaku.NewDouyinDanmakuRecorder(rid, cookies, assFile, cfg, logger) + }) + case "斗鱼": + r.startDanmakuRecorder(ctx, fileName, "斗鱼", resolvedConfig, + func(rid, cookies, assFile string, cfg configs.DanmakuConfig, logger *logrus.Entry) danmakuRecorder { + return danmaku.NewDouyuDanmakuRecorder(rid, cookies, assFile, cfg, logger) + }) } } else { // 弹幕未启用,清理旧的录制器 @@ -714,8 +693,49 @@ func (r *recorder) tryRecord(ctx context.Context) { } } -// stopRetryForExplicitOffline 在平台已明确给出“已下播”信号时补发一次 LiveEnd, -// 让 recorder manager 走正常回收流程,避免 recorder 永久停留在“录制准备中”。 +// danmakuRecorderFactory 弹幕录制器工厂函数类型 +type danmakuRecorderFactory func(roomID, cookies, outputFile string, cfg configs.DanmakuConfig, logger *logrus.Entry) danmakuRecorder + +// startDanmakuRecorder 弹幕录制通用启动流程:解析房间ID、创建录制器、启动、替换旧录制器。 +func (r *recorder) startDanmakuRecorder(ctx context.Context, fileName, platform string, resolvedConfig configs.ResolvedConfig, factory danmakuRecorderFactory) { + assFile := fileName[:strings.LastIndex(fileName, ".")] + ".ass" + cookies := extractCookiesString(r.Live) + + var roomID string + switch platform { + case "哔哩哔哩": + if id := extractRoomIDFromUrl(r.Live.GetRawUrl()); id > 0 { + roomID = strconv.Itoa(id) + } + case "抖音": + roomID = extractDouyinRoomID(r.Live) + case "斗鱼": + roomID = extractDouyuRoomID(r.Live) + } + + if roomID == "" { + r.getLogger().Warn("弹幕录制已启用但无法解析房间ID: " + r.Live.GetRawUrl()) + return + } + + r.getLogger().Infof("弹幕录制已启用,房间ID: %s, 输出: %s", roomID, assFile) + rec := factory(roomID, cookies, assFile, resolvedConfig.Danmaku, r.getLogger().Entry) + if dmErr := rec.Start(ctx); dmErr != nil { + r.getLogger().WithError(dmErr).Warn("弹幕录制启动失败,继续录制视频") + return + } + + r.currentFileLock.Lock() + old := r.danmakuRec + r.danmakuRec = rec + r.currentFileLock.Unlock() + if old != nil { + old.Stop() + } +} + +// stopRetryForExplicitOffline 在平台已明确给出"已下播"信号时补发一次 LiveEnd, +// 让 recorder manager 走正常回收流程,避免 recorder 永久停留在"录制准备中"。 func (r *recorder) stopRetryForExplicitOffline(err error) bool { if !errors.Is(err, live.ErrLiveOffline) { return false @@ -1439,3 +1459,22 @@ func extractDouyinRoomID(l live.Live) string { } return roomID } + +// extractDouyuRoomID 从斗鱼直播 URL 中提取房间号(字符串)。 +// 优先使用 Live 中已解析的数字 roomID(fetchRoomID 从页面解析), +// 避免别名 URL(如 /lotterytimer)传入非数字 ID 导致弹幕登录失败。 +func extractDouyuRoomID(l live.Live) string { + type roomIDProvider interface { + GetRoomID() string + } + if provider, ok := l.(roomIDProvider); ok { + if rid := provider.GetRoomID(); rid != "" { + return rid + } + } + 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..a49eaa3 100644 --- a/src/servers/handler.go +++ b/src/servers/handler.go @@ -1432,16 +1432,26 @@ func applyConfigUpdates(c *configs.Config, updates map[string]interface{}) error c.Danmaku.Resolution = resolution } if outline, ok := danmaku["outline"].(float64); ok { - c.Danmaku.Outline = int(outline) + c.Danmaku.Outline = configs.IntPtr(int(outline)) } if opacity, ok := danmaku["opacity"].(float64); ok { - c.Danmaku.Opacity = int(opacity) + c.Danmaku.Opacity = configs.IntPtr(int(opacity)) } if recordGift, ok := danmaku["record_gift"].(bool); ok { c.Danmaku.RecordGift = configs.BoolPtr(recordGift) } 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 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 { @@ -1717,7 +1727,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) } } @@ -1835,9 +1845,10 @@ func updateRoomConfigById(writer http.ResponseWriter, r *http.Request) { // 更新可覆盖配置 applyOverridableConfigUpdates(&room.OverridableConfig, updates) - // 验证弹幕配置有效性 + // 验证弹幕配置有效性(同时根据平台清理不适用的字段) if room.Danmaku != nil { - if err := room.Danmaku.Validate(); err != nil { + platformKey := configs.GetPlatformKeyFromUrl(room.Url) + if err := room.Danmaku.ValidateWithPlatform(platformKey); err != nil { return fmt.Errorf("弹幕参数无效: %w", err) } } @@ -1985,16 +1996,26 @@ func applyOverridableConfigUpdates(oc *configs.OverridableConfig, updates map[st oc.Danmaku.Resolution = resolution } if outline, ok := danmaku["outline"].(float64); ok { - oc.Danmaku.Outline = int(outline) + oc.Danmaku.Outline = configs.IntPtr(int(outline)) } if opacity, ok := danmaku["opacity"].(float64); ok { - oc.Danmaku.Opacity = int(opacity) + oc.Danmaku.Opacity = configs.IntPtr(int(opacity)) } if recordGift, ok := danmaku["record_gift"].(bool); ok { oc.Danmaku.RecordGift = configs.BoolPtr(recordGift) } 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 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 { @@ -2070,9 +2091,10 @@ 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 { + platformKey := configs.GetPlatformKeyFromUrl(decodedUrl) + if err := room.Danmaku.ValidateWithPlatform(platformKey); err != nil { return fmt.Errorf("弹幕配置无效: %w", err) } } diff --git a/src/webapp/src/component/danmaku-config/index.tsx b/src/webapp/src/component/danmaku-config/index.tsx index 6f7a351..ff4c84f 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,8 @@ const DEFAULT_DANMAKU: DanmakuConfig = { outline: 1, opacity: 128, record_gift: true, + record_douyu_gift: true, + record_douyin_gift: true, record_guard: true, record_super_chat: true, guard_position: 'bottom-left', @@ -31,6 +33,8 @@ interface DanmakuConfig { outline: number; opacity: number; record_gift: boolean; + record_douyu_gift: boolean; + record_douyin_gift: boolean; record_guard: boolean; record_super_chat: boolean; guard_position: string; @@ -75,7 +79,9 @@ const DanmakuParamForm: React.FC<{ label?: string; isRoom?: boolean; showBilibiliContent?: boolean; -}> = ({ initialValues, globalDefaults, onSave, onReset, loading, showEnable, danmakuEnable, label, isRoom, showBilibiliContent }) => { + showDouyuContent?: boolean; + 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]); @@ -200,50 +206,101 @@ 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"> + + + + ), + }]} + /> + )} + + {showDouyinContent && ( + + 礼物消息 用户赠送礼物的通知} + name={['danmaku', 'record_douyin_gift']} valuePropName="checked"> + + + + ), + }]} + /> )} @@ -271,6 +328,7 @@ const DanmakuParamForm: React.FC<{ const DANMAKU_PLATFORMS: Record = { bilibili: '哔哩哔哩', douyin: '抖音', + douyu: '斗鱼', }; const DanmakuSettings: React.FC = () => { @@ -389,12 +447,31 @@ 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 DOUYIN_DANMAKU_FIELDS = ['record_douyin_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]; + } + if (platformKey !== 'douyin') { + for (const f of DOUYIN_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 { @@ -436,6 +513,8 @@ const DanmakuSettings: React.FC = () => { loading={saving} label="全局弹幕" showBilibiliContent + showDouyuContent + showDouyinContent /> @@ -521,9 +600,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, @@ -539,19 +620,21 @@ 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 || '房间弹幕'} isRoom showBilibiliContent={platformKey === 'bilibili'} + showDouyuContent={platformKey === 'douyu'} + showDouyinContent={platformKey === 'douyin'} /> ), }))} /> - ) - ))} + ); + })} ); }; diff --git a/src/webapp/src/component/file-list/index.tsx b/src/webapp/src/component/file-list/index.tsx index 93d1f7e..259518b 100644 --- a/src/webapp/src/component/file-list/index.tsx +++ b/src/webapp/src/component/file-list/index.tsx @@ -121,11 +121,19 @@ const DANMAKU_GAP = 50; // 同轨道弹幕间隔(px) * 这样可以精确跟踪每条弹幕的右边缘,实现真正的多弹幕同轨。 * 同时支持固定位置的消息(Guard/SC/Toast)。 */ +export interface DanmakuFilter { + danmaku: boolean; + gift: boolean; + guard: boolean; + sc: boolean; +} + class DanmakuRenderer { private overlay: HTMLDivElement; private video: HTMLVideoElement; private items: DanmakuEntry[]; private scrollTime: number; + private filter: DanmakuFilter; private rafId = 0; private running = false; private nextIdx = 0; @@ -137,11 +145,24 @@ class DanmakuRenderer { // 滚动样式白名单 private static SCROLL_STYLES = new Set(['Danmaku', 'Gift']); - constructor(overlay: HTMLDivElement, video: HTMLVideoElement, items: DanmakuEntry[], scrollTime: number) { + constructor(overlay: HTMLDivElement, video: HTMLVideoElement, items: DanmakuEntry[], scrollTime: number, filter: DanmakuFilter) { this.overlay = overlay; this.video = video; this.items = items.slice().sort((a, b) => a.start - b.start); this.scrollTime = scrollTime; + this.filter = filter; + } + + private matchesFilter(style: string): boolean { + if (style === 'Danmaku') return this.filter.danmaku; + if (style === 'Gift') return this.filter.gift; + if (style === 'Guard') return this.filter.guard; + if (style.startsWith('SC')) return this.filter.sc; + return true; + } + + getCurrentTime(): number { + return this.video.currentTime; } start() { @@ -232,6 +253,10 @@ class DanmakuRenderer { // 3. 发射新弹幕 while (this.nextIdx < this.items.length && this.items[this.nextIdx].start <= t) { const item = this.items[this.nextIdx]; + if (!this.matchesFilter(item.style)) { + this.nextIdx++; + continue; + } if (DanmakuRenderer.SCROLL_STYLES.has(item.style)) { this.spawnScroll(item, t, cw); } else { @@ -429,6 +454,11 @@ const FileList: React.FC = () => { const [danmakuStats, setDanmakuStats] = useState<{ danmaku: number; gift: number; guard: number; sc: number; scAmount: number } | null>(null); const playerInitRef = useRef(false); // 跟踪播放器是否应该激活 const [loadDanmaku, setLoadDanmaku] = useState(true); // 是否加载 ASS 弹幕 + const [filterDanmaku, setFilterDanmaku] = useState(true); + const [filterGift, setFilterGift] = useState(true); + const [filterGuard, setFilterGuard] = useState(true); + const [filterSC, setFilterSC] = useState(true); + const parsedItemsRef = useRef<{ items: DanmakuEntry[]; scrollTime: number } | null>(null); const currentPlayingRef = useRef<{ record: CurrentFolderFile; fullPath: string } | null>(null); // 重命名相关状态 @@ -573,6 +603,7 @@ const FileList: React.FC = () => { .then(text => { const { items, scrollTime } = parseAss(text); if (items.length === 0) return; + parsedItemsRef.current = { items, scrollTime }; // 统计弹幕类型 let danmakuCount = 0, giftCount = 0, guardCount = 0, scCount = 0, scAmount = 0; @@ -595,7 +626,7 @@ const FileList: React.FC = () => { overlay.className = 'danmaku-overlay'; artInner.appendChild(overlay); - const renderer = new DanmakuRenderer(overlay, artRef.current!.video, items, scrollTime); + const renderer = new DanmakuRenderer(overlay, artRef.current!.video, items, scrollTime, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC }); danmakuRef.current = renderer; renderer.start(); if (artRef.current!.video.currentTime > 0) { @@ -603,7 +634,27 @@ const FileList: React.FC = () => { } }) .catch(() => { /* 没有 ASS 文件或加载失败,静默忽略 */ }); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, [filterDanmaku, filterGift, filterGuard, filterSC]); // eslint-disable-line react-hooks/exhaustive-deps + + // 过滤开关变化时刷新弹幕渲染器 + useEffect(() => { + if (!danmakuRef.current || !parsedItemsRef.current || !artRef.current) return; + const currentTime = danmakuRef.current.getCurrentTime(); + danmakuRef.current.stop(); + + const artContainer = document.getElementById('art-container'); + if (!artContainer) return; + const artInner = artContainer.querySelector('.art-video-player') as HTMLElement || artContainer; + const overlay = document.createElement('div'); + overlay.className = 'danmaku-overlay'; + artInner.appendChild(overlay); + + const { items, scrollTime } = parsedItemsRef.current; + const renderer = new DanmakuRenderer(overlay, artRef.current.video, items, scrollTime, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC }); + danmakuRef.current = renderer; + renderer.start(); + if (currentTime > 0) renderer.seek(currentTime); + }, [filterDanmaku, filterGift, filterGuard, filterSC]); // eslint-disable-line react-hooks/exhaustive-deps const showBatchRenameModal = () => { setBatchFind(""); @@ -916,8 +967,7 @@ const FileList: React.FC = () => { .then(text => { const { items, scrollTime } = parseAss(text); if (items.length === 0) return; - - // 统计弹幕类型 + parsedItemsRef.current = { items, scrollTime }; let danmakuCount = 0, giftCount = 0, guardCount = 0, scCount = 0, scAmount = 0; for (const item of items) { if (item.style === 'Danmaku') danmakuCount++; @@ -942,7 +992,7 @@ const FileList: React.FC = () => { overlay.className = 'danmaku-overlay'; artInner.appendChild(overlay); - const renderer = new DanmakuRenderer(overlay, art.video, items, scrollTime); + const renderer = new DanmakuRenderer(overlay, art.video, items, scrollTime, { danmaku: filterDanmaku, gift: filterGift, guard: filterGuard, sc: filterSC }); danmakuRef.current = renderer; renderer.start(); // 如果浏览器恢复了播放位置,跳转到当前时间 @@ -1131,6 +1181,22 @@ const FileList: React.FC = () => { checked={loadDanmaku} onChange={toggleDanmaku} /> + {loadDanmaku && ( + <> + | + {([ + { label: '文字', checked: filterDanmaku, onChange: setFilterDanmaku }, + { label: '礼物', checked: filterGift, onChange: setFilterGift }, + { label: '上舰', checked: filterGuard, onChange: setFilterGuard }, + { label: 'SC', checked: filterSC, onChange: setFilterSC }, + ] as const).map(({ label, checked, onChange }) => ( +
+ {label} + +
+ ))} + + )} )}
@@ -1141,20 +1207,20 @@ const FileList: React.FC = () => {
{danmakuStats && (
- + 💬 弹幕 {danmakuStats.danmaku} - + 🎁 礼物 {danmakuStats.gift} - + 💰 SC {danmakuStats.sc} {danmakuStats.scAmount > 0 && ¥{danmakuStats.scAmount}} - + ⚓ 上舰 {danmakuStats.guard}