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:
1 parent
5d1efb64f6
commit
257453a70b
6 files changed
+119
-65
No files matched your search
@@ -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
@@ -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):
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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流。
|
||||
|
||||
@@ -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流。
|
||||
|
||||
Reference in new issue
Block a user