18 files changed
+1542
-359
No files matched your search
@@ -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) {
|
||||
|
||||
+117
-45
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
// 在通用位置应用平台访问频率限制
|
||||
// 如果被取消则直接返回
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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++
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+84
-45
@@ -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, "/")
|
||||
}
|
||||
+31
-9
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
</div>
|
||||
|
||||
{showBilibiliContent && (
|
||||
<>
|
||||
<Divider plain style={{ margin: '16px 0', fontSize: 13 }}>哔哩哔哩录制内容</Divider>
|
||||
<Collapse
|
||||
defaultActiveKey={[]}
|
||||
size="small"
|
||||
style={{ marginBottom: 16 }}
|
||||
items={[{
|
||||
key: 'bilibili-content',
|
||||
label: '哔哩哔哩录制内容',
|
||||
children: (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>
|
||||
<Form.Item
|
||||
label={<span>礼物消息 <span style={{ fontWeight: 400, fontSize: 12, color: '#999' }}>用户赠送礼物的通知</span></span>}
|
||||
name={['danmaku', 'record_gift']} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={<span>上舰消息 <span style={{ fontWeight: 400, fontSize: 12, color: '#999' }}>开通舰长/提督/总督的通知</span></span>}
|
||||
name={['danmaku', 'record_guard']} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={<span>醒目留言 (SC) <span style={{ fontWeight: 400, fontSize: 12, color: '#999' }}>Super Chat 付费留言</span></span>}
|
||||
name={['danmaku', 'record_super_chat']} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>
|
||||
<Form.Item
|
||||
label={<span>礼物消息 <span style={{ fontWeight: 400, fontSize: 12, color: '#999' }}>用户赠送礼物的通知</span></span>}
|
||||
name={['danmaku', 'record_gift']} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={<span>上舰消息 <span style={{ fontWeight: 400, fontSize: 12, color: '#999' }}>开通舰长/提督/总督的通知</span></span>}
|
||||
name={['danmaku', 'record_guard']} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={<span>醒目留言 (SC) <span style={{ fontWeight: 400, fontSize: 12, color: '#999' }}>Super Chat 付费留言</span></span>}
|
||||
name={['danmaku', 'record_super_chat']} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>
|
||||
<Form.Item
|
||||
label="上舰消息位置"
|
||||
name={['danmaku', 'guard_position']}>
|
||||
<Select options={[
|
||||
{ label: '左下角', value: 'bottom-left' },
|
||||
{ label: '右下角', value: 'bottom-right' },
|
||||
{ label: '左上角', value: 'top-left' },
|
||||
{ label: '右上角', value: 'top-right' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="SC 消息位置"
|
||||
name={['danmaku', 'sc_position']}>
|
||||
<Select options={[
|
||||
{ label: '左下角', value: 'bottom-left' },
|
||||
{ label: '右下角', value: 'bottom-right' },
|
||||
{ label: '左上角', value: 'top-left' },
|
||||
{ label: '右上角', value: 'top-right' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
}]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>
|
||||
<Form.Item
|
||||
label="上舰消息位置"
|
||||
name={['danmaku', 'guard_position']}>
|
||||
<Select options={[
|
||||
{ label: '左下角', value: 'bottom-left' },
|
||||
{ label: '右下角', value: 'bottom-right' },
|
||||
{ label: '左上角', value: 'top-left' },
|
||||
{ label: '右上角', value: 'top-right' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="SC 消息位置"
|
||||
name={['danmaku', 'sc_position']}>
|
||||
<Select options={[
|
||||
{ label: '左下角', value: 'bottom-left' },
|
||||
{ label: '右下角', value: 'bottom-right' },
|
||||
{ label: '左上角', value: 'top-left' },
|
||||
{ label: '右上角', value: 'top-right' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</>
|
||||
{showDouyuContent && (
|
||||
<Collapse
|
||||
defaultActiveKey={[]}
|
||||
size="small"
|
||||
style={{ marginBottom: 16 }}
|
||||
items={[{
|
||||
key: 'douyu-content',
|
||||
label: '斗鱼录制内容',
|
||||
children: (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>
|
||||
<Form.Item
|
||||
label={<span>礼物消息 <span style={{ fontWeight: 400, fontSize: 12, color: '#999' }}>用户赠送礼物的通知</span></span>}
|
||||
name={['danmaku', 'record_douyu_gift']} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
}]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDouyinContent && (
|
||||
<Collapse
|
||||
defaultActiveKey={[]}
|
||||
size="small"
|
||||
style={{ marginBottom: 16 }}
|
||||
items={[{
|
||||
key: 'douyin-content',
|
||||
label: '抖音录制内容',
|
||||
children: (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>
|
||||
<Form.Item
|
||||
label={<span>礼物消息 <span style={{ fontWeight: 400, fontSize: 12, color: '#999' }}>用户赠送礼物的通知</span></span>}
|
||||
name={['danmaku', 'record_douyin_gift']} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
}]}
|
||||
/>
|
||||
)}
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Space>
|
||||
@@ -271,6 +328,7 @@ const DanmakuParamForm: React.FC<{
|
||||
const DANMAKU_PLATFORMS: Record<string, string> = {
|
||||
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<string, any>, platformKey: string): Record<string, any> => {
|
||||
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
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -521,9 +600,11 @@ const DanmakuSettings: React.FC = () => {
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{Object.entries(platformRooms).map(([platformKey, rooms]) => (
|
||||
rooms.length > 0 && (
|
||||
<Card key={platformKey} title={`房间设置 (${DANMAKU_PLATFORMS[platformKey] || platformKey})`} size="small" style={{ marginBottom: 16 }}>
|
||||
{Object.keys(DANMAKU_PLATFORMS).map((platformKey) => {
|
||||
const rooms = platformRooms[platformKey];
|
||||
if (!rooms || rooms.length === 0) return null;
|
||||
return (
|
||||
<Card key={platformKey} title={`房间设置 (${DANMAKU_PLATFORMS[platformKey]})`} size="small" style={{ marginBottom: 16 }}>
|
||||
<Collapse
|
||||
items={rooms.map((room) => ({
|
||||
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'}
|
||||
/>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
<span style={{ color: '#ffffff40' }}>|</span>
|
||||
{([
|
||||
{ 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 }) => (
|
||||
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }}>
|
||||
<span style={{ color: checked ? '#1890ff' : '#ffffff73' }}>{label}</span>
|
||||
<Switch size="small" checked={checked} onChange={onChange} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="close-btn" onClick={hidePlayer} title="退出播放 (Esc)">
|
||||
@@ -1141,20 +1207,20 @@ const FileList: React.FC = () => {
|
||||
<div id="art-container"></div>
|
||||
{danmakuStats && (
|
||||
<div className="danmaku-stats">
|
||||
<span className="stat-item">
|
||||
<span className="stat-item" style={{ opacity: filterDanmaku ? 1 : 0.4 }}>
|
||||
<span className="stat-icon" style={{ color: '#1890ff' }}>💬</span>
|
||||
弹幕 <b>{danmakuStats.danmaku}</b>
|
||||
</span>
|
||||
<span className="stat-item">
|
||||
<span className="stat-item" style={{ opacity: filterGift ? 1 : 0.4 }}>
|
||||
<span className="stat-icon" style={{ color: '#faad14' }}>🎁</span>
|
||||
礼物 <b>{danmakuStats.gift}</b>
|
||||
</span>
|
||||
<span className="stat-item">
|
||||
<span className="stat-item" style={{ opacity: filterSC ? 1 : 0.4 }}>
|
||||
<span className="stat-icon" style={{ color: '#ff6a39' }}>💰</span>
|
||||
SC <b>{danmakuStats.sc}</b>
|
||||
{danmakuStats.scAmount > 0 && <span className="stat-amount"> ¥{danmakuStats.scAmount}</span>}
|
||||
</span>
|
||||
<span className="stat-item">
|
||||
<span className="stat-item" style={{ opacity: filterGuard ? 1 : 0.4 }}>
|
||||
<span className="stat-icon" style={{ color: '#ff8c00' }}>⚓</span>
|
||||
上舰 <b>{danmakuStats.guard}</b>
|
||||
</span>
|
||||
|
||||
Reference in new issue
Block a user