feat: 支持B站完整弹幕录制 (#1186)

* feat: 支持B站完整弹幕录制

* feat: 原始弹幕json编码时不编码中文

* mod: 每五分钟保存一次弹幕文件,减少磁盘IO

* feat: 默认场景10秒保存一次弹幕,原始弹幕5分钟一次

* mod: 修改录制原始弹幕参数名称

* mod: 更新示例配置文件

* refactor(Danmaku): 提升代码可读性

---------

Co-authored-by: xxxxuanran <xxxxuanran@gmail.com>
This commit is contained in:
某摆烂咸鱼andxxxxuanran authored and GitHub committed 2025-03-31 02:48:27 +00:00
1 parent 5d1efb64f6
commit 257453a70b
6 files changed
+119 -65

No files matched your search

+9
View File
@@ -72,6 +72,15 @@ const Bilibili: React.FC<Props> = props => {
padding: 0,
}}
/>
<Form.Switch
field="bilibili_danmaku_raw"
extraText="录制B站服务器返回的原始弹幕信息,方便有技术能力的用户对主播弹幕数据进行统计。仅 bilibili_danmaku 开启时生效,默认关闭,开启后弹幕文件会每隔5分钟写入一次(实验性质:可能导致弹幕文件巨大)"
label="录制完整弹幕(bilibili_danmaku_raw)"
fieldStyle={{
alignSelf: 'stretch',
padding: 0,
}}
/>
<Form.Input
field="user.bili_cookie"
extraText={
+91 -65
View File
@@ -154,6 +154,14 @@ class DanmakuClient(IDanmakuClient):
fmt_file_name = time.strftime(self.__file_name.encode("unicode-escape").decode()).encode().decode(
"unicode-escape") + '.xml'
msg_i = 0
# 设置弹幕自动保存时间
last_save_time = int(start_time)
if self.__content.get("raw", False):
save_interval = 300
else:
save_interval = 10
try:
while True:
try:
@@ -162,9 +170,12 @@ class DanmakuClient(IDanmakuClient):
except asyncio.TimeoutError:
continue
logger.debug(f"{DanmakuClient.__name__}:{self.__url}: 弹幕queue-{m.get('msg_type')}")
#print(m)
if m.get('msg_type') == "save":
msg_type = m.get("msg_type")
msg_time = time.time()
logger.debug(f"{DanmakuClient.__name__}:{self.__url}: 弹幕queue-{msg_type}")
# print(m)
# 非弹幕
if msg_type == "save":
if 'file_name' in m and fmt_file_name != m['file_name']:
try:
if os.path.exists(m['file_name']):
@@ -181,7 +192,7 @@ class DanmakuClient(IDanmakuClient):
if callable(m.get('callback')):
m['callback']()
break
elif m.get('msg_type') == "stop":
elif msg_type == "stop":
try:
os.remove(fmt_file_name)
except:
@@ -189,70 +200,85 @@ class DanmakuClient(IDanmakuClient):
fmt_file_name = None
self.__record_task.cancel()
return
# 弹幕信息记录
elif m.get('msg_type') == 'danmaku':
try:
if m.get('color'):
color = m["color"]
else:
color = '16777215'
msg_time = format(time.time() - start_time, '.3f')
# 记录弹幕额外信息
timestamp = str(int(time.time()))
uid = str(m.get("uid",0))
d = etree.SubElement(root, 'd')
'''
弹幕部分的参数兼容bilibili主站弹幕 XML 文件,按顺序含义分别为:
1.弹幕出现时间 (秒)
2.弹幕类型
3.字号
4.颜色
5.发送时间戳
6.固定为 0 (主站弹幕 XML 为弹幕池 ID)
7.发送者 UID (主站弹幕 XML 为发送用户 ID 的 CRC32)
8.固定为 0 (主站弹幕 XML 为弹幕的数据库 ID)
'''
if self.__content.get("detail", None):
d.set('p', f"{msg_time},1,25,{color},{timestamp},0,{uid},0")
d.set('timestamp', timestamp)
d.set('uid',uid)
#兼容DanmakuFactory可识别用户名
d.set('user', m.get("name",""))
else:
d.set('p', f"{msg_time},1,25,{color},0,0,0,0")
d.text = m["content"]
except:
logger.warning(f"{DanmakuClient.__name__}:{self.__url}:弹幕处理异常", exc_info=True)
# 异常后略过本次弹幕
continue
# 礼物信息记录,支持上舰、SC、礼物,目前仅在B站开启
elif m.get('msg_type') in ['gift', 'super_chat' , 'guard_buy'] and self.__u == 'live.bilibili.com':
if not self.__content.get("detail", None):
continue
try:
s = etree.SubElement(root, 's')
s.set('timestamp', str(int(time.time())))
s.set('uid', str(m.get("uid")))
s.set('username', m.get("name",""))
s.set('price', str(m.get("price")))
s.set('type', m.get('msg_type'))
# 礼物名称
s.set('num', str(m.get('num')))
s.set('giftname', m.get('gift_name'))
s.text = m["content"]
except:
logger.warning(f"{DanmakuClient.__name__}:{self.__url}:弹幕处理异常", exc_info=True)
# 异常后略过本次弹幕
continue
else:
continue
msg_i += 1
if msg_i % 5 == 0:
# 每收到五条弹幕后写入 减少io
# 可能会写入失败 会在下次五条或者任务被取消时重新尝试写入
else: # 正常弹幕
# print(m)
if msg_type == 'danmaku': # 文字弹幕
try:
if m.get('color'):
color = m["color"]
else:
color = '16777215'
msg_time_since_start = format(msg_time - start_time, '.3f')
# 记录弹幕额外信息
timestamp = str(int(msg_time))
uid = str(m.get("uid",0))
d = etree.SubElement(root, 'd')
'''
弹幕部分的参数兼容bilibili主站弹幕 XML 文件,按顺序含义分别为:
1.弹幕出现时间 (秒)
2.弹幕类型
3.字号
4.颜色
5.发送时间戳
6.固定为 0 (主站弹幕 XML 为弹幕池 ID)
7.发送者 UID (主站弹幕 XML 为发送用户 ID 的 CRC32)
8.固定为 0 (主站弹幕 XML 为弹幕的数据库 ID)
'''
d.set('p', f"{msg_time_since_start},1,25,{color},{timestamp},0,{uid},0")
if self.__content.get("detail", False):
d.set('timestamp', timestamp)
d.set('uid',uid)
#兼容DanmakuFactory用户名识别
d.set('user', m.get("name",""))
d.text = m["content"]
except:
logger.warning(f"{DanmakuClient.__name__}:{self.__url}:弹幕处理异常", exc_info=True)
# 异常后略过本次弹幕
continue
# 礼物信息记录,支持上舰、SC、礼物,目前仅在B站开启
elif self.__u == 'live.bilibili.com' and msg_type in ['gift', 'super_chat' , 'guard_buy']:
if not self.__content.get("detail", False):
continue
try:
s = etree.SubElement(root, 's')
s.set('timestamp', str(int(msg_time)))
s.set('uid', str(m.get("uid")))
s.set('username', m.get("name",""))
s.set('price', str(m.get("price")))
s.set('type', msg_type)
# 礼物名称
s.set('num', str(m.get('num')))
s.set('giftname', m.get('gift_name'))
s.text = m["content"]
except:
logger.warning(f"{DanmakuClient.__name__}:{self.__url}:弹幕处理异常", exc_info=True)
# 异常后略过本次弹幕
continue
else:
# 完整弹幕记录
if not self.__content.get("raw", False):
continue
try:
o = etree.SubElement(root, 'o')
o.set('timestamp', str(int(msg_time)))
o.text = m.get("raw_data")
except:
logger.warning(f"{DanmakuClient.__name__}:{self.__url}:弹幕处理异常", exc_info=True)
continue
msg_i += 1
# 每隔指定时间接入一次弹幕
time_since_last_save = int(msg_time) - last_save_time
if msg_i > 0 and time_since_last_save >= save_interval:
logger.debug(f'{DanmakuClient.__name__}:{self.__url}: 写入弹幕')
write_file(fmt_file_name)
last_save_time = int(msg_time)
msg_i = 0
# else: # 弹幕未写入原因
# print(f"time_since_last_save: {time_since_last_save}, msg_i: {msg_i}")
finally:
# 发生异常(被取消)时写入 避免丢失未写入
logger.debug(f'{DanmakuClient.__name__}:{self.__url}:弹幕保底写入')
write_file(fmt_file_name)
def start(self):
+9
View File
@@ -101,6 +101,11 @@ class Bilibili:
packet_data = packet_data[packet_len:]
return dm_list
def bytes_serializer(obj):
if isinstance(obj, bytes):
return obj.decode('utf-8')
raise TypeError("Type not serializable")
dm_list = decode_packet(data)
for dm in dm_list:
try:
@@ -170,6 +175,10 @@ class Bilibili:
msg['content'] = j
else:
msg = {'name': '', 'content': dm.get('body'), 'msg_type': 'other'}
try:
msg['raw_data'] = json.dumps(dm, default=bytes_serializer, ensure_ascii=False)
except:
msg['raw_data'] = ""
msgs.append(msg)
except Exception as Error:
logger.warning(f"{Bilibili.__name__}: 弹幕接收异常 - {Error}")
+2
View File
@@ -21,6 +21,7 @@ class Bililive(DownloadBase):
self.live_start_time = 0
self.bilibili_danmaku = config.get('bilibili_danmaku', False)
self.bilibili_danmaku_detail = config.get('bilibili_danmaku_detail', False)
self.bilibili_danmaku_raw = config.get('bilibili_danmaku_raw', False)
self.__real_room_id = None
self.__login_mid = 0
self.bili_cookie = config.get('user', {}).get('bili_cookie')
@@ -213,6 +214,7 @@ class Bililive(DownloadBase):
'room_id': self.__real_room_id,
'cookie': self.fake_headers.get('cookie', ''),
'detail': self.bilibili_danmaku_detail,
'raw': self.bilibili_danmaku_raw,
'uid': self.__login_mid
}
)
+4
View File
@@ -88,6 +88,10 @@ check_sourcecode = 15
#------哔哩哔哩------#
### 录制BILIBILI弹幕,目前暂时不支持视频按时长分段下的弹幕文件自动分段,默认关闭
# bilibili_danmaku = false
### 录制的弹幕信息中包含发送者昵称、用户UID,同时保存醒目留言、上舰、礼物信息。仅 bilibili_danmaku 开启时生效,默认关闭(实验性质:可能与弹幕转ass工具不兼容)
# bilibili_danmaku_detail = false
### 录制B站服务器返回的原始弹幕信息,方便有技术能力的用户对主播弹幕数据进行统计。仅 bilibili_danmaku 开启时生效,默认关闭,开启后弹幕文件会每隔5分钟写入一次(实验性质:可能导致弹幕文件巨大)
# bilibili_danmaku_raw = false
### 哔哩哔哩直播流协议.可选 stream(flv流),hls_ts(ts流)与hls_fmp4(fmp4流),默认为stream
### 仅国内IP可以解析到fmp4流。海外IP只能获取到flv流(ov05与ov07)和ts流(ov105)
### 由于fmp4出现需要一定时间,或者某些小主播(大部分只有原画选项的主播)无fmp4流。
+4
View File
@@ -87,6 +87,10 @@ check_sourcecode: 15
#------哔哩哔哩------#
### 录制BILIBILI弹幕,下载器为ffmpeg,streamlink时效果最优,默认关闭
# bilibili_danmaku: false
### 录制的弹幕信息中包含发送者昵称、用户UID,同时保存醒目留言、上舰、礼物信息。仅 bilibili_danmaku 开启时生效,默认关闭(实验性质:可能与弹幕转ass工具不兼容)
# bilibili_danmaku_detail: false
### 录制B站服务器返回的原始弹幕信息,方便有技术能力的用户对主播弹幕数据进行统计。仅 bilibili_danmaku 开启时生效,默认关闭,开启后弹幕文件会每隔5分钟写入一次(实验性质:可能导致弹幕文件巨大)
# bilibili_danmaku_raw: false
### 哔哩哔哩直播流协议.可选 stream(flv流),hls_ts(ts流)与hls_fmp4(fmp4流),默认为stream
### 仅国内IP可以解析到fmp4流。海外IP只能获取到flv流(ov05与ov07)和ts流(ov105)
### 由于fmp4出现需要一定时间,或者某些小主播(大部分只有原画选项的主播)无fmp4流。