Spaces:
Configuration error
Configuration error
void
commited on
Commit
•
163d304
1
Parent(s):
7f7db32
fork xfgryujk/blivedm
Browse files- blivedm/__init__.py +3 -0
- blivedm/clients/__init__.py +3 -0
- blivedm/clients/open_live.py +293 -0
- blivedm/clients/web.py +266 -0
- blivedm/clients/ws_base.py +494 -0
- blivedm/handlers.py +199 -0
- blivedm/models/__init__.py +1 -0
- blivedm/models/open_live.py +390 -0
- blivedm/models/web.py +390 -0
- blivedm/utils.py +19 -0
blivedm/__init__.py
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
# -*- coding: utf-8 -*-
|
2 |
+
from .handlers import *
|
3 |
+
from .clients import *
|
blivedm/clients/__init__.py
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
# -*- coding: utf-8 -*-
|
2 |
+
from .web import *
|
3 |
+
from .open_live import *
|
blivedm/clients/open_live.py
ADDED
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# -*- coding: utf-8 -*-
|
2 |
+
import asyncio
|
3 |
+
import datetime
|
4 |
+
import hashlib
|
5 |
+
import hmac
|
6 |
+
import json
|
7 |
+
import logging
|
8 |
+
import uuid
|
9 |
+
from typing import *
|
10 |
+
|
11 |
+
import aiohttp
|
12 |
+
|
13 |
+
from . import ws_base
|
14 |
+
|
15 |
+
__all__ = (
|
16 |
+
'OpenLiveClient',
|
17 |
+
)
|
18 |
+
|
19 |
+
logger = logging.getLogger('blivedm')
|
20 |
+
|
21 |
+
START_URL = 'https://live-open.biliapi.com/v2/app/start'
|
22 |
+
HEARTBEAT_URL = 'https://live-open.biliapi.com/v2/app/heartbeat'
|
23 |
+
END_URL = 'https://live-open.biliapi.com/v2/app/end'
|
24 |
+
|
25 |
+
|
26 |
+
class OpenLiveClient(ws_base.WebSocketClientBase):
|
27 |
+
"""
|
28 |
+
开放平台客户端
|
29 |
+
|
30 |
+
文档参考:https://open-live.bilibili.com/document/
|
31 |
+
|
32 |
+
:param access_key_id: 在开放平台申请的access_key_id
|
33 |
+
:param access_key_secret: 在开放平台申请的access_key_secret
|
34 |
+
:param app_id: 在开放平台创建的项目ID
|
35 |
+
:param room_owner_auth_code: 主播身份码
|
36 |
+
:param session: cookie、连接池
|
37 |
+
:param heartbeat_interval: 发送连接心跳包的间隔时间(秒)
|
38 |
+
:param game_heartbeat_interval: 发送项目心跳包的间隔时间(秒)
|
39 |
+
"""
|
40 |
+
|
41 |
+
def __init__(
|
42 |
+
self,
|
43 |
+
access_key_id: str,
|
44 |
+
access_key_secret: str,
|
45 |
+
app_id: int,
|
46 |
+
room_owner_auth_code: str,
|
47 |
+
*,
|
48 |
+
session: Optional[aiohttp.ClientSession] = None,
|
49 |
+
heartbeat_interval=30,
|
50 |
+
game_heartbeat_interval=20,
|
51 |
+
):
|
52 |
+
super().__init__(session, heartbeat_interval)
|
53 |
+
|
54 |
+
self._access_key_id = access_key_id
|
55 |
+
self._access_key_secret = access_key_secret
|
56 |
+
self._app_id = app_id
|
57 |
+
self._room_owner_auth_code = room_owner_auth_code
|
58 |
+
self._game_heartbeat_interval = game_heartbeat_interval
|
59 |
+
|
60 |
+
# 在调用init_room后初始化的字段
|
61 |
+
self._room_owner_uid: Optional[int] = None
|
62 |
+
"""主播用户ID"""
|
63 |
+
self._room_owner_open_id: Optional[str] = None
|
64 |
+
"""主播Open ID"""
|
65 |
+
self._host_server_url_list: Optional[List[str]] = []
|
66 |
+
"""弹幕服务器URL列表"""
|
67 |
+
self._auth_body: Optional[str] = None
|
68 |
+
"""连接弹幕服务器用的认证包内容"""
|
69 |
+
self._game_id: Optional[str] = None
|
70 |
+
"""项目场次ID"""
|
71 |
+
|
72 |
+
# 在运行时初始化的字段
|
73 |
+
self._game_heartbeat_timer_handle: Optional[asyncio.TimerHandle] = None
|
74 |
+
"""发项目心跳包定时器的handle"""
|
75 |
+
|
76 |
+
@property
|
77 |
+
def room_owner_uid(self) -> Optional[int]:
|
78 |
+
"""
|
79 |
+
主播用户ID,调用init_room后初始化
|
80 |
+
"""
|
81 |
+
return self._room_owner_uid
|
82 |
+
|
83 |
+
@property
|
84 |
+
def room_owner_open_id(self) -> Optional[str]:
|
85 |
+
"""
|
86 |
+
主播Open ID,调用init_room后初始化
|
87 |
+
"""
|
88 |
+
return self._room_owner_open_id
|
89 |
+
|
90 |
+
@property
|
91 |
+
def room_owner_auth_code(self):
|
92 |
+
"""
|
93 |
+
主播身份码
|
94 |
+
"""
|
95 |
+
return self._room_owner_auth_code
|
96 |
+
|
97 |
+
@property
|
98 |
+
def app_id(self):
|
99 |
+
"""
|
100 |
+
在开放平台创建的项目ID
|
101 |
+
"""
|
102 |
+
return self._app_id
|
103 |
+
|
104 |
+
@property
|
105 |
+
def game_id(self) -> Optional[str]:
|
106 |
+
"""
|
107 |
+
项目场次ID,调用init_room后初始化
|
108 |
+
"""
|
109 |
+
return self._game_id
|
110 |
+
|
111 |
+
async def close(self):
|
112 |
+
"""
|
113 |
+
释放本客户端的资源,调用后本客户端将不可用
|
114 |
+
"""
|
115 |
+
if self.is_running:
|
116 |
+
logger.warning('room=%s is calling close(), but client is running', self.room_id)
|
117 |
+
|
118 |
+
if self._game_heartbeat_timer_handle is not None:
|
119 |
+
self._game_heartbeat_timer_handle.cancel()
|
120 |
+
self._game_heartbeat_timer_handle = None
|
121 |
+
await self._end_game()
|
122 |
+
|
123 |
+
await super().close()
|
124 |
+
|
125 |
+
def _request_open_live(self, url, body: dict):
|
126 |
+
body_bytes = json.dumps(body).encode('utf-8')
|
127 |
+
headers = {
|
128 |
+
'x-bili-accesskeyid': self._access_key_id,
|
129 |
+
'x-bili-content-md5': hashlib.md5(body_bytes).hexdigest(),
|
130 |
+
'x-bili-signature-method': 'HMAC-SHA256',
|
131 |
+
'x-bili-signature-nonce': uuid.uuid4().hex,
|
132 |
+
'x-bili-signature-version': '1.0',
|
133 |
+
'x-bili-timestamp': str(int(datetime.datetime.now().timestamp())),
|
134 |
+
}
|
135 |
+
|
136 |
+
str_to_sign = '\n'.join(
|
137 |
+
f'{key}:{value}'
|
138 |
+
for key, value in headers.items()
|
139 |
+
)
|
140 |
+
signature = hmac.new(
|
141 |
+
self._access_key_secret.encode('utf-8'), str_to_sign.encode('utf-8'), hashlib.sha256
|
142 |
+
).hexdigest()
|
143 |
+
headers['Authorization'] = signature
|
144 |
+
|
145 |
+
headers['Content-Type'] = 'application/json'
|
146 |
+
headers['Accept'] = 'application/json'
|
147 |
+
return self._session.post(url, headers=headers, data=body_bytes)
|
148 |
+
|
149 |
+
async def init_room(self):
|
150 |
+
"""
|
151 |
+
开启项目,并初始化连接房间需要的字段
|
152 |
+
|
153 |
+
:return: 是否成功
|
154 |
+
"""
|
155 |
+
if not await self._start_game():
|
156 |
+
return False
|
157 |
+
|
158 |
+
if self._game_id != '' and self._game_heartbeat_timer_handle is None:
|
159 |
+
self._game_heartbeat_timer_handle = asyncio.get_running_loop().call_later(
|
160 |
+
self._game_heartbeat_interval, self._on_send_game_heartbeat
|
161 |
+
)
|
162 |
+
return True
|
163 |
+
|
164 |
+
async def _start_game(self):
|
165 |
+
try:
|
166 |
+
async with self._request_open_live(
|
167 |
+
START_URL,
|
168 |
+
{'code': self._room_owner_auth_code, 'app_id': self._app_id}
|
169 |
+
) as res:
|
170 |
+
if res.status != 200:
|
171 |
+
logger.warning('_start_game() failed, status=%d, reason=%s', res.status, res.reason)
|
172 |
+
return False
|
173 |
+
data = await res.json()
|
174 |
+
if data['code'] != 0:
|
175 |
+
logger.warning('_start_game() failed, code=%d, message=%s, request_id=%s',
|
176 |
+
data['code'], data['message'], data['request_id'])
|
177 |
+
return False
|
178 |
+
if not self._parse_start_game(data['data']):
|
179 |
+
return False
|
180 |
+
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
181 |
+
logger.exception('_start_game() failed:')
|
182 |
+
return False
|
183 |
+
return True
|
184 |
+
|
185 |
+
def _parse_start_game(self, data):
|
186 |
+
self._game_id = data['game_info']['game_id']
|
187 |
+
websocket_info = data['websocket_info']
|
188 |
+
self._auth_body = websocket_info['auth_body']
|
189 |
+
self._host_server_url_list = websocket_info['wss_link']
|
190 |
+
anchor_info = data['anchor_info']
|
191 |
+
self._room_id = anchor_info['room_id']
|
192 |
+
self._room_owner_uid = anchor_info['uid']
|
193 |
+
self._room_owner_open_id = anchor_info['open_id']
|
194 |
+
return True
|
195 |
+
|
196 |
+
async def _end_game(self):
|
197 |
+
"""
|
198 |
+
关闭项目。建议关闭客户端时保证调用到这个函数(close会调用),否则可能短时间内无法重复连接同一个房间
|
199 |
+
"""
|
200 |
+
if self._game_id in (None, ''):
|
201 |
+
return True
|
202 |
+
|
203 |
+
try:
|
204 |
+
async with self._request_open_live(
|
205 |
+
END_URL,
|
206 |
+
{'app_id': self._app_id, 'game_id': self._game_id}
|
207 |
+
) as res:
|
208 |
+
if res.status != 200:
|
209 |
+
logger.warning('room=%d _end_game() failed, status=%d, reason=%s',
|
210 |
+
self._room_id, res.status, res.reason)
|
211 |
+
return False
|
212 |
+
data = await res.json()
|
213 |
+
code = data['code']
|
214 |
+
if code != 0:
|
215 |
+
if code in (7000, 7003):
|
216 |
+
# 项目已经关闭了也算成功
|
217 |
+
return True
|
218 |
+
|
219 |
+
logger.warning('room=%d _end_game() failed, code=%d, message=%s, request_id=%s',
|
220 |
+
self._room_id, code, data['message'], data['request_id'])
|
221 |
+
return False
|
222 |
+
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
223 |
+
logger.exception('room=%d _end_game() failed:', self._room_id)
|
224 |
+
return False
|
225 |
+
return True
|
226 |
+
|
227 |
+
def _on_send_game_heartbeat(self):
|
228 |
+
"""
|
229 |
+
定时发送项目心跳包的回调
|
230 |
+
"""
|
231 |
+
self._game_heartbeat_timer_handle = asyncio.get_running_loop().call_later(
|
232 |
+
self._game_heartbeat_interval, self._on_send_game_heartbeat
|
233 |
+
)
|
234 |
+
asyncio.create_task(self._send_game_heartbeat())
|
235 |
+
|
236 |
+
async def _send_game_heartbeat(self):
|
237 |
+
"""
|
238 |
+
发送项目心跳包
|
239 |
+
"""
|
240 |
+
if self._game_id in (None, ''):
|
241 |
+
logger.warning('game=%d _send_game_heartbeat() failed, game_id not found', self._game_id)
|
242 |
+
return False
|
243 |
+
|
244 |
+
try:
|
245 |
+
# 保存一下,防止await之后game_id改变
|
246 |
+
game_id = self._game_id
|
247 |
+
async with self._request_open_live(
|
248 |
+
HEARTBEAT_URL,
|
249 |
+
{'game_id': game_id}
|
250 |
+
) as res:
|
251 |
+
if res.status != 200:
|
252 |
+
logger.warning('room=%d _send_game_heartbeat() failed, status=%d, reason=%s',
|
253 |
+
self._room_id, res.status, res.reason)
|
254 |
+
return False
|
255 |
+
data = await res.json()
|
256 |
+
code = data['code']
|
257 |
+
if code != 0:
|
258 |
+
logger.warning('room=%d _send_game_heartbeat() failed, code=%d, message=%s, request_id=%s',
|
259 |
+
self._room_id, code, data['message'], data['request_id'])
|
260 |
+
|
261 |
+
if code == 7003 and self._game_id == game_id:
|
262 |
+
# 项目异常关闭,可能是心跳超时,需要重新开启项目
|
263 |
+
self._need_init_room = True
|
264 |
+
if self._websocket is not None and not self._websocket.closed:
|
265 |
+
await self._websocket.close()
|
266 |
+
|
267 |
+
return False
|
268 |
+
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
269 |
+
logger.exception('room=%d _send_game_heartbeat() failed:', self._room_id)
|
270 |
+
return False
|
271 |
+
return True
|
272 |
+
|
273 |
+
async def _on_before_ws_connect(self, retry_count):
|
274 |
+
"""
|
275 |
+
在每次建立连接之前调用,可以用来初始化房间
|
276 |
+
"""
|
277 |
+
# 重连次数太多则重新init_room,保险
|
278 |
+
reinit_period = max(3, len(self._host_server_url_list or ()))
|
279 |
+
if retry_count > 0 and retry_count % reinit_period == 0:
|
280 |
+
self._need_init_room = True
|
281 |
+
await super()._on_before_ws_connect(retry_count)
|
282 |
+
|
283 |
+
def _get_ws_url(self, retry_count) -> str:
|
284 |
+
"""
|
285 |
+
返回WebSocket连接的URL,可以在这里做故障转移和负载均衡
|
286 |
+
"""
|
287 |
+
return self._host_server_url_list[retry_count % len(self._host_server_url_list)]
|
288 |
+
|
289 |
+
async def _send_auth(self):
|
290 |
+
"""
|
291 |
+
发送认证包
|
292 |
+
"""
|
293 |
+
await self._websocket.send_bytes(self._make_packet(self._auth_body, ws_base.Operation.AUTH))
|
blivedm/clients/web.py
ADDED
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# -*- coding: utf-8 -*-
|
2 |
+
import asyncio
|
3 |
+
import logging
|
4 |
+
from typing import *
|
5 |
+
|
6 |
+
import aiohttp
|
7 |
+
import yarl
|
8 |
+
|
9 |
+
from . import ws_base
|
10 |
+
from .. import utils
|
11 |
+
|
12 |
+
__all__ = (
|
13 |
+
'BLiveClient',
|
14 |
+
)
|
15 |
+
|
16 |
+
logger = logging.getLogger('blivedm')
|
17 |
+
|
18 |
+
UID_INIT_URL = 'https://api.bilibili.com/x/web-interface/nav'
|
19 |
+
BUVID_INIT_URL = 'https://data.bilibili.com/v/'
|
20 |
+
ROOM_INIT_URL = 'https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom'
|
21 |
+
DANMAKU_SERVER_CONF_URL = 'https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo'
|
22 |
+
DEFAULT_DANMAKU_SERVER_LIST = [
|
23 |
+
{'host': 'broadcastlv.chat.bilibili.com', 'port': 2243, 'wss_port': 443, 'ws_port': 2244}
|
24 |
+
]
|
25 |
+
|
26 |
+
|
27 |
+
class BLiveClient(ws_base.WebSocketClientBase):
|
28 |
+
"""
|
29 |
+
web端客户端
|
30 |
+
|
31 |
+
:param room_id: URL中的房间ID,可以用短ID
|
32 |
+
:param uid: B站用户ID,0表示未登录,None表示自动获取
|
33 |
+
:param session: cookie、连接池
|
34 |
+
:param heartbeat_interval: 发送心跳包的间隔时间(秒)
|
35 |
+
"""
|
36 |
+
|
37 |
+
def __init__(
|
38 |
+
self,
|
39 |
+
room_id: int,
|
40 |
+
*,
|
41 |
+
uid: Optional[int] = None,
|
42 |
+
session: Optional[aiohttp.ClientSession] = None,
|
43 |
+
heartbeat_interval=30,
|
44 |
+
):
|
45 |
+
super().__init__(session, heartbeat_interval)
|
46 |
+
|
47 |
+
self._tmp_room_id = room_id
|
48 |
+
"""用来init_room的临时房间ID,可以用短ID"""
|
49 |
+
self._uid = uid
|
50 |
+
|
51 |
+
# 在调用init_room后初始化的字段
|
52 |
+
self._room_owner_uid: Optional[int] = None
|
53 |
+
"""主播用户ID"""
|
54 |
+
self._host_server_list: Optional[List[dict]] = None
|
55 |
+
"""
|
56 |
+
弹幕服务器列表
|
57 |
+
|
58 |
+
`[{host: "tx-bj4-live-comet-04.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244}, ...]`
|
59 |
+
"""
|
60 |
+
self._host_server_token: Optional[str] = None
|
61 |
+
"""连接弹幕服务器用的token"""
|
62 |
+
|
63 |
+
@property
|
64 |
+
def tmp_room_id(self) -> int:
|
65 |
+
"""
|
66 |
+
构造时传进来的room_id参数
|
67 |
+
"""
|
68 |
+
return self._tmp_room_id
|
69 |
+
|
70 |
+
@property
|
71 |
+
def room_owner_uid(self) -> Optional[int]:
|
72 |
+
"""
|
73 |
+
主播用户ID,调用init_room后初始化
|
74 |
+
"""
|
75 |
+
return self._room_owner_uid
|
76 |
+
|
77 |
+
@property
|
78 |
+
def uid(self) -> Optional[int]:
|
79 |
+
"""
|
80 |
+
当前登录的用户ID,未登录则为0,调用init_room后初始化
|
81 |
+
"""
|
82 |
+
return self._uid
|
83 |
+
|
84 |
+
async def init_room(self):
|
85 |
+
"""
|
86 |
+
初始化连接房间需要的字段
|
87 |
+
|
88 |
+
:return: True代表没有降级,如果需要降级后还可用,重载这个函数返回True
|
89 |
+
"""
|
90 |
+
if self._uid is None:
|
91 |
+
if not await self._init_uid():
|
92 |
+
logger.warning('room=%d _init_uid() failed', self._tmp_room_id)
|
93 |
+
self._uid = 0
|
94 |
+
|
95 |
+
if self._get_buvid() == '':
|
96 |
+
if not await self._init_buvid():
|
97 |
+
logger.warning('room=%d _init_buvid() failed', self._tmp_room_id)
|
98 |
+
|
99 |
+
res = True
|
100 |
+
if not await self._init_room_id_and_owner():
|
101 |
+
res = False
|
102 |
+
# 失败了则降级
|
103 |
+
self._room_id = self._tmp_room_id
|
104 |
+
self._room_owner_uid = 0
|
105 |
+
|
106 |
+
if not await self._init_host_server():
|
107 |
+
res = False
|
108 |
+
# 失败了则降级
|
109 |
+
self._host_server_list = DEFAULT_DANMAKU_SERVER_LIST
|
110 |
+
self._host_server_token = None
|
111 |
+
return res
|
112 |
+
|
113 |
+
async def _init_uid(self):
|
114 |
+
cookies = self._session.cookie_jar.filter_cookies(yarl.URL(UID_INIT_URL))
|
115 |
+
sessdata_cookie = cookies.get('SESSDATA', None)
|
116 |
+
if sessdata_cookie is None or sessdata_cookie.value == '':
|
117 |
+
# cookie都没有,不用请求了
|
118 |
+
self._uid = 0
|
119 |
+
return True
|
120 |
+
|
121 |
+
try:
|
122 |
+
async with self._session.get(
|
123 |
+
UID_INIT_URL,
|
124 |
+
headers={'User-Agent': utils.USER_AGENT},
|
125 |
+
) as res:
|
126 |
+
if res.status != 200:
|
127 |
+
logger.warning('room=%d _init_uid() failed, status=%d, reason=%s', self._tmp_room_id,
|
128 |
+
res.status, res.reason)
|
129 |
+
return False
|
130 |
+
data = await res.json()
|
131 |
+
if data['code'] != 0:
|
132 |
+
if data['code'] == -101:
|
133 |
+
# 未登录
|
134 |
+
self._uid = 0
|
135 |
+
return True
|
136 |
+
logger.warning('room=%d _init_uid() failed, message=%s', self._tmp_room_id,
|
137 |
+
data['message'])
|
138 |
+
return False
|
139 |
+
|
140 |
+
data = data['data']
|
141 |
+
if not data['isLogin']:
|
142 |
+
# 未登录
|
143 |
+
self._uid = 0
|
144 |
+
else:
|
145 |
+
self._uid = data['mid']
|
146 |
+
return True
|
147 |
+
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
148 |
+
logger.exception('room=%d _init_uid() failed:', self._tmp_room_id)
|
149 |
+
return False
|
150 |
+
|
151 |
+
def _get_buvid(self):
|
152 |
+
cookies = self._session.cookie_jar.filter_cookies(yarl.URL(BUVID_INIT_URL))
|
153 |
+
buvid_cookie = cookies.get('buvid3', None)
|
154 |
+
if buvid_cookie is None:
|
155 |
+
return ''
|
156 |
+
return buvid_cookie.value
|
157 |
+
|
158 |
+
async def _init_buvid(self):
|
159 |
+
try:
|
160 |
+
async with self._session.get(
|
161 |
+
BUVID_INIT_URL,
|
162 |
+
headers={'User-Agent': utils.USER_AGENT},
|
163 |
+
) as res:
|
164 |
+
if res.status != 200:
|
165 |
+
logger.warning('room=%d _init_buvid() status error, status=%d, reason=%s',
|
166 |
+
self._tmp_room_id, res.status, res.reason)
|
167 |
+
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
168 |
+
logger.exception('room=%d _init_buvid() exception:', self._tmp_room_id)
|
169 |
+
return self._get_buvid() != ''
|
170 |
+
|
171 |
+
async def _init_room_id_and_owner(self):
|
172 |
+
try:
|
173 |
+
async with self._session.get(
|
174 |
+
ROOM_INIT_URL,
|
175 |
+
headers={'User-Agent': utils.USER_AGENT},
|
176 |
+
params={
|
177 |
+
'room_id': self._tmp_room_id
|
178 |
+
},
|
179 |
+
) as res:
|
180 |
+
if res.status != 200:
|
181 |
+
logger.warning('room=%d _init_room_id_and_owner() failed, status=%d, reason=%s', self._tmp_room_id,
|
182 |
+
res.status, res.reason)
|
183 |
+
return False
|
184 |
+
data = await res.json()
|
185 |
+
if data['code'] != 0:
|
186 |
+
logger.warning('room=%d _init_room_id_and_owner() failed, message=%s', self._tmp_room_id,
|
187 |
+
data['message'])
|
188 |
+
return False
|
189 |
+
if not self._parse_room_init(data['data']):
|
190 |
+
return False
|
191 |
+
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
192 |
+
logger.exception('room=%d _init_room_id_and_owner() failed:', self._tmp_room_id)
|
193 |
+
return False
|
194 |
+
return True
|
195 |
+
|
196 |
+
def _parse_room_init(self, data):
|
197 |
+
room_info = data['room_info']
|
198 |
+
self._room_id = room_info['room_id']
|
199 |
+
self._room_owner_uid = room_info['uid']
|
200 |
+
return True
|
201 |
+
|
202 |
+
async def _init_host_server(self):
|
203 |
+
try:
|
204 |
+
async with self._session.get(
|
205 |
+
DANMAKU_SERVER_CONF_URL,
|
206 |
+
headers={'User-Agent': utils.USER_AGENT},
|
207 |
+
params={
|
208 |
+
'id': self._room_id,
|
209 |
+
'type': 0
|
210 |
+
},
|
211 |
+
) as res:
|
212 |
+
if res.status != 200:
|
213 |
+
logger.warning('room=%d _init_host_server() failed, status=%d, reason=%s', self._room_id,
|
214 |
+
res.status, res.reason)
|
215 |
+
return False
|
216 |
+
data = await res.json()
|
217 |
+
if data['code'] != 0:
|
218 |
+
logger.warning('room=%d _init_host_server() failed, message=%s', self._room_id, data['message'])
|
219 |
+
return False
|
220 |
+
if not self._parse_danmaku_server_conf(data['data']):
|
221 |
+
return False
|
222 |
+
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
223 |
+
logger.exception('room=%d _init_host_server() failed:', self._room_id)
|
224 |
+
return False
|
225 |
+
return True
|
226 |
+
|
227 |
+
def _parse_danmaku_server_conf(self, data):
|
228 |
+
self._host_server_list = data['host_list']
|
229 |
+
self._host_server_token = data['token']
|
230 |
+
if not self._host_server_list:
|
231 |
+
logger.warning('room=%d _parse_danmaku_server_conf() failed: host_server_list is empty', self._room_id)
|
232 |
+
return False
|
233 |
+
return True
|
234 |
+
|
235 |
+
async def _on_before_ws_connect(self, retry_count):
|
236 |
+
"""
|
237 |
+
在每次建立连接之前调用,可以用来初始化房间
|
238 |
+
"""
|
239 |
+
# 重连次数太多则重新init_room,保险
|
240 |
+
reinit_period = max(3, len(self._host_server_list or ()))
|
241 |
+
if retry_count > 0 and retry_count % reinit_period == 0:
|
242 |
+
self._need_init_room = True
|
243 |
+
await super()._on_before_ws_connect(retry_count)
|
244 |
+
|
245 |
+
def _get_ws_url(self, retry_count) -> str:
|
246 |
+
"""
|
247 |
+
返回WebSocket连接的URL,可以在这里做故障转移和负载均衡
|
248 |
+
"""
|
249 |
+
host_server = self._host_server_list[retry_count % len(self._host_server_list)]
|
250 |
+
return f"wss://{host_server['host']}:{host_server['wss_port']}/sub"
|
251 |
+
|
252 |
+
async def _send_auth(self):
|
253 |
+
"""
|
254 |
+
发送认证包
|
255 |
+
"""
|
256 |
+
auth_params = {
|
257 |
+
'uid': self._uid,
|
258 |
+
'roomid': self._room_id,
|
259 |
+
'protover': 3,
|
260 |
+
'platform': 'web',
|
261 |
+
'type': 2,
|
262 |
+
'buvid': self._get_buvid(),
|
263 |
+
}
|
264 |
+
if self._host_server_token is not None:
|
265 |
+
auth_params['key'] = self._host_server_token
|
266 |
+
await self._websocket.send_bytes(self._make_packet(auth_params, ws_base.Operation.AUTH))
|
blivedm/clients/ws_base.py
ADDED
@@ -0,0 +1,494 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# -*- coding: utf-8 -*-
|
2 |
+
import asyncio
|
3 |
+
import enum
|
4 |
+
import json
|
5 |
+
import logging
|
6 |
+
import struct
|
7 |
+
import zlib
|
8 |
+
from typing import *
|
9 |
+
|
10 |
+
import aiohttp
|
11 |
+
import brotli
|
12 |
+
|
13 |
+
from .. import handlers, utils
|
14 |
+
|
15 |
+
logger = logging.getLogger('blivedm')
|
16 |
+
|
17 |
+
USER_AGENT = (
|
18 |
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36'
|
19 |
+
)
|
20 |
+
|
21 |
+
HEADER_STRUCT = struct.Struct('>I2H2I')
|
22 |
+
|
23 |
+
|
24 |
+
class HeaderTuple(NamedTuple):
|
25 |
+
pack_len: int
|
26 |
+
raw_header_size: int
|
27 |
+
ver: int
|
28 |
+
operation: int
|
29 |
+
seq_id: int
|
30 |
+
|
31 |
+
|
32 |
+
# WS_BODY_PROTOCOL_VERSION
|
33 |
+
class ProtoVer(enum.IntEnum):
|
34 |
+
NORMAL = 0
|
35 |
+
HEARTBEAT = 1
|
36 |
+
DEFLATE = 2
|
37 |
+
BROTLI = 3
|
38 |
+
|
39 |
+
|
40 |
+
# go-common\app\service\main\broadcast\model\operation.go
|
41 |
+
class Operation(enum.IntEnum):
|
42 |
+
HANDSHAKE = 0
|
43 |
+
HANDSHAKE_REPLY = 1
|
44 |
+
HEARTBEAT = 2
|
45 |
+
HEARTBEAT_REPLY = 3
|
46 |
+
SEND_MSG = 4
|
47 |
+
SEND_MSG_REPLY = 5
|
48 |
+
DISCONNECT_REPLY = 6
|
49 |
+
AUTH = 7
|
50 |
+
AUTH_REPLY = 8
|
51 |
+
RAW = 9
|
52 |
+
PROTO_READY = 10
|
53 |
+
PROTO_FINISH = 11
|
54 |
+
CHANGE_ROOM = 12
|
55 |
+
CHANGE_ROOM_REPLY = 13
|
56 |
+
REGISTER = 14
|
57 |
+
REGISTER_REPLY = 15
|
58 |
+
UNREGISTER = 16
|
59 |
+
UNREGISTER_REPLY = 17
|
60 |
+
# B站业务自定义OP
|
61 |
+
# MinBusinessOp = 1000
|
62 |
+
# MaxBusinessOp = 10000
|
63 |
+
|
64 |
+
|
65 |
+
# WS_AUTH
|
66 |
+
class AuthReplyCode(enum.IntEnum):
|
67 |
+
OK = 0
|
68 |
+
TOKEN_ERROR = -101
|
69 |
+
|
70 |
+
|
71 |
+
class InitError(Exception):
|
72 |
+
"""初始化失败"""
|
73 |
+
|
74 |
+
|
75 |
+
class AuthError(Exception):
|
76 |
+
"""认证失败"""
|
77 |
+
|
78 |
+
|
79 |
+
DEFAULT_RECONNECT_POLICY = utils.make_constant_retry_policy(1)
|
80 |
+
|
81 |
+
|
82 |
+
class WebSocketClientBase:
|
83 |
+
"""
|
84 |
+
基于WebSocket的客户端
|
85 |
+
|
86 |
+
:param session: cookie、连接池
|
87 |
+
:param heartbeat_interval: 发送心跳包的间隔时间(秒)
|
88 |
+
"""
|
89 |
+
|
90 |
+
def __init__(
|
91 |
+
self,
|
92 |
+
session: Optional[aiohttp.ClientSession] = None,
|
93 |
+
heartbeat_interval: float = 30,
|
94 |
+
):
|
95 |
+
if session is None:
|
96 |
+
self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
|
97 |
+
self._own_session = True
|
98 |
+
else:
|
99 |
+
self._session = session
|
100 |
+
self._own_session = False
|
101 |
+
assert self._session.loop is asyncio.get_event_loop() # noqa
|
102 |
+
|
103 |
+
self._heartbeat_interval = heartbeat_interval
|
104 |
+
|
105 |
+
self._need_init_room = True
|
106 |
+
self._handler: Optional[handlers.HandlerInterface] = None
|
107 |
+
"""消息处理器"""
|
108 |
+
self._get_reconnect_interval: Callable[[int, int], float] = DEFAULT_RECONNECT_POLICY
|
109 |
+
"""重连间隔时间增长策略"""
|
110 |
+
|
111 |
+
# 在调用init_room后初始化的字段
|
112 |
+
self._room_id: Optional[int] = None
|
113 |
+
|
114 |
+
# 在运行时初始化的字段
|
115 |
+
self._websocket: Optional[aiohttp.ClientWebSocketResponse] = None
|
116 |
+
"""WebSocket连接"""
|
117 |
+
self._network_future: Optional[asyncio.Future] = None
|
118 |
+
"""网络协程的future"""
|
119 |
+
self._heartbeat_timer_handle: Optional[asyncio.TimerHandle] = None
|
120 |
+
"""发心跳包定时器的handle"""
|
121 |
+
|
122 |
+
@property
|
123 |
+
def is_running(self) -> bool:
|
124 |
+
"""
|
125 |
+
本客户端正在运行,注意调用stop后还没完全停止也算正在运行
|
126 |
+
"""
|
127 |
+
return self._network_future is not None
|
128 |
+
|
129 |
+
@property
|
130 |
+
def room_id(self) -> Optional[int]:
|
131 |
+
"""
|
132 |
+
房间ID,调用init_room后初始化
|
133 |
+
"""
|
134 |
+
return self._room_id
|
135 |
+
|
136 |
+
def set_handler(self, handler: Optional['handlers.HandlerInterface']):
|
137 |
+
"""
|
138 |
+
设置消息处理器
|
139 |
+
|
140 |
+
注意消息处理器和网络协程运行在同一个协程,如果处理消息耗时太长会阻塞接收消息。如果是CPU密集型的任务,建议将消息推到线程池处理;
|
141 |
+
如果是IO密集型的任务,应该使用async函数,并且在handler里使用create_task创建新的协程
|
142 |
+
|
143 |
+
:param handler: 消息处理器
|
144 |
+
"""
|
145 |
+
self._handler = handler
|
146 |
+
|
147 |
+
def set_reconnect_policy(self, get_reconnect_interval: Callable[[int, int], float]):
|
148 |
+
"""
|
149 |
+
设置重连间隔时间增长策略
|
150 |
+
|
151 |
+
:param get_reconnect_interval: 一个可调用对象,输入重试次数 (retry_count, total_retry_count),返回间隔时间
|
152 |
+
"""
|
153 |
+
self._get_reconnect_interval = get_reconnect_interval
|
154 |
+
|
155 |
+
def start(self):
|
156 |
+
"""
|
157 |
+
启动本客户端
|
158 |
+
"""
|
159 |
+
if self.is_running:
|
160 |
+
logger.warning('room=%s client is running, cannot start() again', self.room_id)
|
161 |
+
return
|
162 |
+
|
163 |
+
self._network_future = asyncio.create_task(self._network_coroutine_wrapper())
|
164 |
+
|
165 |
+
def stop(self):
|
166 |
+
"""
|
167 |
+
停止本客户端
|
168 |
+
"""
|
169 |
+
if not self.is_running:
|
170 |
+
logger.warning('room=%s client is stopped, cannot stop() again', self.room_id)
|
171 |
+
return
|
172 |
+
|
173 |
+
self._network_future.cancel()
|
174 |
+
|
175 |
+
async def stop_and_close(self):
|
176 |
+
"""
|
177 |
+
便利函数,停止本客户端并释放本客户端的资源,调用后本客户端将不可用
|
178 |
+
"""
|
179 |
+
if self.is_running:
|
180 |
+
self.stop()
|
181 |
+
await self.join()
|
182 |
+
await self.close()
|
183 |
+
|
184 |
+
async def join(self):
|
185 |
+
"""
|
186 |
+
等待本客户端停止
|
187 |
+
"""
|
188 |
+
if not self.is_running:
|
189 |
+
logger.warning('room=%s client is stopped, cannot join()', self.room_id)
|
190 |
+
return
|
191 |
+
|
192 |
+
await asyncio.shield(self._network_future)
|
193 |
+
|
194 |
+
async def close(self):
|
195 |
+
"""
|
196 |
+
释放本客户端的资源,调用后本客户端将不可用
|
197 |
+
"""
|
198 |
+
if self.is_running:
|
199 |
+
logger.warning('room=%s is calling close(), but client is running', self.room_id)
|
200 |
+
|
201 |
+
# 如果session是自己创建的则关闭session
|
202 |
+
if self._own_session:
|
203 |
+
await self._session.close()
|
204 |
+
|
205 |
+
async def init_room(self) -> bool:
|
206 |
+
"""
|
207 |
+
初始化连接房间需要的字段
|
208 |
+
|
209 |
+
:return: True代表没有降级,如果需要降级后还可用,重载这个函数返回True
|
210 |
+
"""
|
211 |
+
raise NotImplementedError
|
212 |
+
|
213 |
+
@staticmethod
|
214 |
+
def _make_packet(data: Union[dict, str, bytes], operation: int) -> bytes:
|
215 |
+
"""
|
216 |
+
创建一个要发送给服务器的包
|
217 |
+
|
218 |
+
:param data: 包体JSON数据
|
219 |
+
:param operation: 操作码,见Operation
|
220 |
+
:return: 整个包的数据
|
221 |
+
"""
|
222 |
+
if isinstance(data, dict):
|
223 |
+
body = json.dumps(data).encode('utf-8')
|
224 |
+
elif isinstance(data, str):
|
225 |
+
body = data.encode('utf-8')
|
226 |
+
else:
|
227 |
+
body = data
|
228 |
+
header = HEADER_STRUCT.pack(*HeaderTuple(
|
229 |
+
pack_len=HEADER_STRUCT.size + len(body),
|
230 |
+
raw_header_size=HEADER_STRUCT.size,
|
231 |
+
ver=1,
|
232 |
+
operation=operation,
|
233 |
+
seq_id=1
|
234 |
+
))
|
235 |
+
return header + body
|
236 |
+
|
237 |
+
async def _network_coroutine_wrapper(self):
|
238 |
+
"""
|
239 |
+
负责处理网络协程的异常,网络协程具体逻辑在_network_coroutine里
|
240 |
+
"""
|
241 |
+
exc = None
|
242 |
+
try:
|
243 |
+
await self._network_coroutine()
|
244 |
+
except asyncio.CancelledError:
|
245 |
+
# 正常停止
|
246 |
+
pass
|
247 |
+
except Exception as e:
|
248 |
+
logger.exception('room=%s _network_coroutine() finished with exception:', self.room_id)
|
249 |
+
exc = e
|
250 |
+
finally:
|
251 |
+
logger.debug('room=%s _network_coroutine() finished', self.room_id)
|
252 |
+
self._network_future = None
|
253 |
+
|
254 |
+
if self._handler is not None:
|
255 |
+
self._handler.on_client_stopped(self, exc)
|
256 |
+
|
257 |
+
async def _network_coroutine(self):
|
258 |
+
"""
|
259 |
+
网络协程,负责连接服务器、接收消息、解包
|
260 |
+
"""
|
261 |
+
# retry_count在连接成功后会重置为0,total_retry_count不会
|
262 |
+
retry_count = 0
|
263 |
+
total_retry_count = 0
|
264 |
+
while True:
|
265 |
+
try:
|
266 |
+
await self._on_before_ws_connect(retry_count)
|
267 |
+
|
268 |
+
# 连接
|
269 |
+
async with self._session.ws_connect(
|
270 |
+
self._get_ws_url(retry_count),
|
271 |
+
headers={'User-Agent': utils.USER_AGENT}, # web端的token也会签名UA
|
272 |
+
receive_timeout=self._heartbeat_interval + 5,
|
273 |
+
) as websocket:
|
274 |
+
self._websocket = websocket
|
275 |
+
await self._on_ws_connect()
|
276 |
+
|
277 |
+
# 处理消息
|
278 |
+
message: aiohttp.WSMessage
|
279 |
+
async for message in websocket:
|
280 |
+
await self._on_ws_message(message)
|
281 |
+
# 至少成功处理1条消息
|
282 |
+
retry_count = 0
|
283 |
+
|
284 |
+
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
285 |
+
# 掉线重连
|
286 |
+
pass
|
287 |
+
except AuthError:
|
288 |
+
# 认证失败了,应该重新获取token再重连
|
289 |
+
logger.exception('room=%d auth failed, trying init_room() again', self.room_id)
|
290 |
+
self._need_init_room = True
|
291 |
+
finally:
|
292 |
+
self._websocket = None
|
293 |
+
await self._on_ws_close()
|
294 |
+
|
295 |
+
# 准备重连
|
296 |
+
retry_count += 1
|
297 |
+
total_retry_count += 1
|
298 |
+
logger.warning(
|
299 |
+
'room=%d is reconnecting, retry_count=%d, total_retry_count=%d',
|
300 |
+
self.room_id, retry_count, total_retry_count
|
301 |
+
)
|
302 |
+
await asyncio.sleep(self._get_reconnect_interval(retry_count, total_retry_count))
|
303 |
+
|
304 |
+
async def _on_before_ws_connect(self, retry_count):
|
305 |
+
"""
|
306 |
+
在每次建立连接之前调用,可以用来初始化房间
|
307 |
+
"""
|
308 |
+
if not self._need_init_room:
|
309 |
+
return
|
310 |
+
|
311 |
+
if not await self.init_room():
|
312 |
+
raise InitError('init_room() failed')
|
313 |
+
self._need_init_room = False
|
314 |
+
|
315 |
+
def _get_ws_url(self, retry_count) -> str:
|
316 |
+
"""
|
317 |
+
返回WebSocket连接的URL,可以在这里做故障转移和负载均衡
|
318 |
+
"""
|
319 |
+
raise NotImplementedError
|
320 |
+
|
321 |
+
async def _on_ws_connect(self):
|
322 |
+
"""
|
323 |
+
WebSocket连接成功
|
324 |
+
"""
|
325 |
+
await self._send_auth()
|
326 |
+
self._heartbeat_timer_handle = asyncio.get_running_loop().call_later(
|
327 |
+
self._heartbeat_interval, self._on_send_heartbeat
|
328 |
+
)
|
329 |
+
|
330 |
+
async def _on_ws_close(self):
|
331 |
+
"""
|
332 |
+
WebSocket连接断开
|
333 |
+
"""
|
334 |
+
if self._heartbeat_timer_handle is not None:
|
335 |
+
self._heartbeat_timer_handle.cancel()
|
336 |
+
self._heartbeat_timer_handle = None
|
337 |
+
|
338 |
+
async def _send_auth(self):
|
339 |
+
"""
|
340 |
+
发送认证包
|
341 |
+
"""
|
342 |
+
raise NotImplementedError
|
343 |
+
|
344 |
+
def _on_send_heartbeat(self):
|
345 |
+
"""
|
346 |
+
定时发送心跳包的回调
|
347 |
+
"""
|
348 |
+
if self._websocket is None or self._websocket.closed:
|
349 |
+
self._heartbeat_timer_handle = None
|
350 |
+
return
|
351 |
+
|
352 |
+
self._heartbeat_timer_handle = asyncio.get_running_loop().call_later(
|
353 |
+
self._heartbeat_interval, self._on_send_heartbeat
|
354 |
+
)
|
355 |
+
asyncio.create_task(self._send_heartbeat())
|
356 |
+
|
357 |
+
async def _send_heartbeat(self):
|
358 |
+
"""
|
359 |
+
发送心跳包
|
360 |
+
"""
|
361 |
+
if self._websocket is None or self._websocket.closed:
|
362 |
+
return
|
363 |
+
|
364 |
+
try:
|
365 |
+
await self._websocket.send_bytes(self._make_packet({}, Operation.HEARTBEAT))
|
366 |
+
except (ConnectionResetError, aiohttp.ClientConnectionError) as e:
|
367 |
+
logger.warning('room=%d _send_heartbeat() failed: %r', self.room_id, e)
|
368 |
+
except Exception: # noqa
|
369 |
+
logger.exception('room=%d _send_heartbeat() failed:', self.room_id)
|
370 |
+
|
371 |
+
async def _on_ws_message(self, message: aiohttp.WSMessage):
|
372 |
+
"""
|
373 |
+
收到WebSocket消息
|
374 |
+
|
375 |
+
:param message: WebSocket消息
|
376 |
+
"""
|
377 |
+
if message.type != aiohttp.WSMsgType.BINARY:
|
378 |
+
logger.warning('room=%d unknown websocket message type=%s, data=%s', self.room_id,
|
379 |
+
message.type, message.data)
|
380 |
+
return
|
381 |
+
|
382 |
+
try:
|
383 |
+
await self._parse_ws_message(message.data)
|
384 |
+
except AuthError:
|
385 |
+
# 认证失败,让外层处理
|
386 |
+
raise
|
387 |
+
except Exception: # noqa
|
388 |
+
logger.exception('room=%d _parse_ws_message() error:', self.room_id)
|
389 |
+
|
390 |
+
async def _parse_ws_message(self, data: bytes):
|
391 |
+
"""
|
392 |
+
解析WebSocket消息
|
393 |
+
|
394 |
+
:param data: WebSocket消息数据
|
395 |
+
"""
|
396 |
+
offset = 0
|
397 |
+
try:
|
398 |
+
header = HeaderTuple(*HEADER_STRUCT.unpack_from(data, offset))
|
399 |
+
except struct.error:
|
400 |
+
logger.exception('room=%d parsing header failed, offset=%d, data=%s', self.room_id, offset, data)
|
401 |
+
return
|
402 |
+
|
403 |
+
if header.operation in (Operation.SEND_MSG_REPLY, Operation.AUTH_REPLY):
|
404 |
+
# 业务消息,可能有多个包一起发,需要分包
|
405 |
+
while True:
|
406 |
+
body = data[offset + header.raw_header_size: offset + header.pack_len]
|
407 |
+
await self._parse_business_message(header, body)
|
408 |
+
|
409 |
+
offset += header.pack_len
|
410 |
+
if offset >= len(data):
|
411 |
+
break
|
412 |
+
|
413 |
+
try:
|
414 |
+
header = HeaderTuple(*HEADER_STRUCT.unpack_from(data, offset))
|
415 |
+
except struct.error:
|
416 |
+
logger.exception('room=%d parsing header failed, offset=%d, data=%s', self.room_id, offset, data)
|
417 |
+
break
|
418 |
+
|
419 |
+
elif header.operation == Operation.HEARTBEAT_REPLY:
|
420 |
+
# 服务器心跳包,前4字节是人气值,后面是客户端发的心跳包内容
|
421 |
+
# pack_len不包括客户端发的心跳包内容,不知道是不是服务器BUG
|
422 |
+
body = data[offset + header.raw_header_size: offset + header.raw_header_size + 4]
|
423 |
+
popularity = int.from_bytes(body, 'big')
|
424 |
+
# 自己造个消息当成业务消息处理
|
425 |
+
body = {
|
426 |
+
'cmd': '_HEARTBEAT',
|
427 |
+
'data': {
|
428 |
+
'popularity': popularity
|
429 |
+
}
|
430 |
+
}
|
431 |
+
self._handle_command(body)
|
432 |
+
|
433 |
+
else:
|
434 |
+
# 未知消息
|
435 |
+
body = data[offset + header.raw_header_size: offset + header.pack_len]
|
436 |
+
logger.warning('room=%d unknown message operation=%d, header=%s, body=%s', self.room_id,
|
437 |
+
header.operation, header, body)
|
438 |
+
|
439 |
+
async def _parse_business_message(self, header: HeaderTuple, body: bytes):
|
440 |
+
"""
|
441 |
+
解析业务消息
|
442 |
+
"""
|
443 |
+
if header.operation == Operation.SEND_MSG_REPLY:
|
444 |
+
# 业务消息
|
445 |
+
if header.ver == ProtoVer.BROTLI:
|
446 |
+
# 压缩过的先解压,为了避免阻塞网络线程,放在其他线程执行
|
447 |
+
body = await asyncio.get_running_loop().run_in_executor(None, brotli.decompress, body)
|
448 |
+
await self._parse_ws_message(body)
|
449 |
+
elif header.ver == ProtoVer.DEFLATE:
|
450 |
+
# web端已经不用zlib压缩了,但是开放平台会用
|
451 |
+
body = await asyncio.get_running_loop().run_in_executor(None, zlib.decompress, body)
|
452 |
+
await self._parse_ws_message(body)
|
453 |
+
elif header.ver == ProtoVer.NORMAL:
|
454 |
+
# 没压缩过的直接反序列化,因为有万恶的GIL,这里不能并行避免阻塞
|
455 |
+
if len(body) != 0:
|
456 |
+
try:
|
457 |
+
body = json.loads(body.decode('utf-8'))
|
458 |
+
self._handle_command(body)
|
459 |
+
except Exception:
|
460 |
+
logger.error('room=%d, body=%s', self.room_id, body)
|
461 |
+
raise
|
462 |
+
else:
|
463 |
+
# 未知格式
|
464 |
+
logger.warning('room=%d unknown protocol version=%d, header=%s, body=%s', self.room_id,
|
465 |
+
header.ver, header, body)
|
466 |
+
|
467 |
+
elif header.operation == Operation.AUTH_REPLY:
|
468 |
+
# 认证响应
|
469 |
+
body = json.loads(body.decode('utf-8'))
|
470 |
+
if body['code'] != AuthReplyCode.OK:
|
471 |
+
raise AuthError(f"auth reply error, code={body['code']}, body={body}")
|
472 |
+
await self._websocket.send_bytes(self._make_packet({}, Operation.HEARTBEAT))
|
473 |
+
|
474 |
+
else:
|
475 |
+
# 未知消息
|
476 |
+
logger.warning('room=%d unknown message operation=%d, header=%s, body=%s', self.room_id,
|
477 |
+
header.operation, header, body)
|
478 |
+
|
479 |
+
def _handle_command(self, command: dict):
|
480 |
+
"""
|
481 |
+
处理业务消息
|
482 |
+
|
483 |
+
:param command: 业务消息
|
484 |
+
"""
|
485 |
+
if self._handler is None:
|
486 |
+
return
|
487 |
+
try:
|
488 |
+
# 为什么不做成异步的:
|
489 |
+
# 1. 为了保持处理消息的顺序,这里不使用call_soon、create_task等方法延迟处理
|
490 |
+
# 2. 如果支持handle使用async函数,用户可能会在里面处理耗时很长的异步操作,导致网络协程阻塞
|
491 |
+
# 这里做成同步的,强制用户使用create_task或消息队列处理异步操作,这样就不会阻塞网络协程
|
492 |
+
self._handler.handle(self, command)
|
493 |
+
except Exception as e:
|
494 |
+
logger.exception('room=%d _handle_command() failed, command=%s', self.room_id, command, exc_info=e)
|
blivedm/handlers.py
ADDED
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# -*- coding: utf-8 -*-
|
2 |
+
import logging
|
3 |
+
from typing import *
|
4 |
+
|
5 |
+
from .clients import ws_base
|
6 |
+
from .models import web as web_models, open_live as open_models
|
7 |
+
|
8 |
+
__all__ = (
|
9 |
+
'HandlerInterface',
|
10 |
+
'BaseHandler',
|
11 |
+
)
|
12 |
+
|
13 |
+
logger = logging.getLogger('blivedm')
|
14 |
+
|
15 |
+
logged_unknown_cmds = {
|
16 |
+
'COMBO_SEND',
|
17 |
+
'ENTRY_EFFECT',
|
18 |
+
'HOT_RANK_CHANGED',
|
19 |
+
'HOT_RANK_CHANGED_V2',
|
20 |
+
'INTERACT_WORD',
|
21 |
+
'LIVE',
|
22 |
+
'LIVE_INTERACTIVE_GAME',
|
23 |
+
'NOTICE_MSG',
|
24 |
+
'ONLINE_RANK_COUNT',
|
25 |
+
'ONLINE_RANK_TOP3',
|
26 |
+
'ONLINE_RANK_V2',
|
27 |
+
'PK_BATTLE_END',
|
28 |
+
'PK_BATTLE_FINAL_PROCESS',
|
29 |
+
'PK_BATTLE_PROCESS',
|
30 |
+
'PK_BATTLE_PROCESS_NEW',
|
31 |
+
'PK_BATTLE_SETTLE',
|
32 |
+
'PK_BATTLE_SETTLE_USER',
|
33 |
+
'PK_BATTLE_SETTLE_V2',
|
34 |
+
'PREPARING',
|
35 |
+
'ROOM_REAL_TIME_MESSAGE_UPDATE',
|
36 |
+
'STOP_LIVE_ROOM_LIST',
|
37 |
+
'SUPER_CHAT_MESSAGE_JPN',
|
38 |
+
'WIDGET_BANNER',
|
39 |
+
}
|
40 |
+
"""已打日志的未知cmd"""
|
41 |
+
|
42 |
+
|
43 |
+
class HandlerInterface:
|
44 |
+
"""
|
45 |
+
直播消息处理器接口
|
46 |
+
"""
|
47 |
+
|
48 |
+
def handle(self, client: ws_base.WebSocketClientBase, command: dict):
|
49 |
+
raise NotImplementedError
|
50 |
+
|
51 |
+
def on_client_stopped(self, client: ws_base.WebSocketClientBase, exception: Optional[Exception]):
|
52 |
+
"""
|
53 |
+
当客户端停止时调用。可以在这里close或者重新start
|
54 |
+
"""
|
55 |
+
|
56 |
+
|
57 |
+
def _make_msg_callback(method_name, message_cls):
|
58 |
+
def callback(self: 'BaseHandler', client: ws_base.WebSocketClientBase, command: dict):
|
59 |
+
method = getattr(self, method_name)
|
60 |
+
return method(client, message_cls.from_command(command['data']))
|
61 |
+
return callback
|
62 |
+
|
63 |
+
|
64 |
+
class BaseHandler(HandlerInterface):
|
65 |
+
"""
|
66 |
+
一个简单的消息处理器实现,带消息分发和消息类型转换。继承并重写_on_xxx方法即可实现自己的处理器
|
67 |
+
"""
|
68 |
+
|
69 |
+
def __danmu_msg_callback(self, client: ws_base.WebSocketClientBase, command: dict):
|
70 |
+
return self._on_danmaku(client, web_models.DanmakuMessage.from_command(command['info']))
|
71 |
+
|
72 |
+
_CMD_CALLBACK_DICT: Dict[
|
73 |
+
str,
|
74 |
+
Optional[Callable[
|
75 |
+
['BaseHandler', ws_base.WebSocketClientBase, dict],
|
76 |
+
Any
|
77 |
+
]]
|
78 |
+
] = {
|
79 |
+
# 收到心跳包,这是blivedm自造的消息,原本的心跳包格式不一样
|
80 |
+
'_HEARTBEAT': _make_msg_callback('_on_heartbeat', web_models.HeartbeatMessage),
|
81 |
+
# 收到弹幕
|
82 |
+
# go-common\app\service\live\live-dm\service\v1\send.go
|
83 |
+
'DANMU_MSG': __danmu_msg_callback,
|
84 |
+
# 有人送礼
|
85 |
+
'SEND_GIFT': _make_msg_callback('_on_gift', web_models.GiftMessage),
|
86 |
+
# 有人上舰
|
87 |
+
'GUARD_BUY': _make_msg_callback('_on_buy_guard', web_models.GuardBuyMessage),
|
88 |
+
# 醒目留言
|
89 |
+
'SUPER_CHAT_MESSAGE': _make_msg_callback('_on_super_chat', web_models.SuperChatMessage),
|
90 |
+
# 删除醒目留言
|
91 |
+
'SUPER_CHAT_MESSAGE_DELETE': _make_msg_callback('_on_super_chat_delete', web_models.SuperChatDeleteMessage),
|
92 |
+
|
93 |
+
#
|
94 |
+
# 开放平台消息
|
95 |
+
#
|
96 |
+
|
97 |
+
# 收到弹幕
|
98 |
+
'LIVE_OPEN_PLATFORM_DM': _make_msg_callback('_on_open_live_danmaku', open_models.DanmakuMessage),
|
99 |
+
# 有人送礼
|
100 |
+
'LIVE_OPEN_PLATFORM_SEND_GIFT': _make_msg_callback('_on_open_live_gift', open_models.GiftMessage),
|
101 |
+
# 有人上舰
|
102 |
+
'LIVE_OPEN_PLATFORM_GUARD': _make_msg_callback('_on_open_live_buy_guard', open_models.GuardBuyMessage),
|
103 |
+
# 醒目留言
|
104 |
+
'LIVE_OPEN_PLATFORM_SUPER_CHAT': _make_msg_callback('_on_open_live_super_chat', open_models.SuperChatMessage),
|
105 |
+
# 删除醒目留言
|
106 |
+
'LIVE_OPEN_PLATFORM_SUPER_CHAT_DEL': _make_msg_callback(
|
107 |
+
'_on_open_live_super_chat_delete', open_models.SuperChatDeleteMessage
|
108 |
+
),
|
109 |
+
# 点赞
|
110 |
+
'LIVE_OPEN_PLATFORM_LIKE': _make_msg_callback('_on_open_live_like', open_models.LikeMessage),
|
111 |
+
}
|
112 |
+
"""cmd -> 处理回调"""
|
113 |
+
|
114 |
+
def handle(self, client: ws_base.WebSocketClientBase, command: dict):
|
115 |
+
cmd = command.get('cmd', '')
|
116 |
+
pos = cmd.find(':') # 2019-5-29 B站弹幕升级新增了参数
|
117 |
+
if pos != -1:
|
118 |
+
cmd = cmd[:pos]
|
119 |
+
|
120 |
+
if cmd not in self._CMD_CALLBACK_DICT:
|
121 |
+
# 只有第一次遇到未知cmd时打日志
|
122 |
+
if cmd not in logged_unknown_cmds:
|
123 |
+
logger.warning('room=%d unknown cmd=%s, command=%s', client.room_id, cmd, command)
|
124 |
+
logged_unknown_cmds.add(cmd)
|
125 |
+
return
|
126 |
+
|
127 |
+
callback = self._CMD_CALLBACK_DICT[cmd]
|
128 |
+
if callback is not None:
|
129 |
+
callback(self, client, command)
|
130 |
+
|
131 |
+
def _on_heartbeat(self, client: ws_base.WebSocketClientBase, message: web_models.HeartbeatMessage):
|
132 |
+
"""
|
133 |
+
收到心跳包
|
134 |
+
"""
|
135 |
+
|
136 |
+
def _on_danmaku(self, client: ws_base.WebSocketClientBase, message: web_models.DanmakuMessage):
|
137 |
+
"""
|
138 |
+
收到弹幕
|
139 |
+
"""
|
140 |
+
|
141 |
+
def _on_gift(self, client: ws_base.WebSocketClientBase, message: web_models.GiftMessage):
|
142 |
+
"""
|
143 |
+
收到礼物
|
144 |
+
"""
|
145 |
+
|
146 |
+
def _on_buy_guard(self, client: ws_base.WebSocketClientBase, message: web_models.GuardBuyMessage):
|
147 |
+
"""
|
148 |
+
有人上舰
|
149 |
+
"""
|
150 |
+
|
151 |
+
def _on_super_chat(self, client: ws_base.WebSocketClientBase, message: web_models.SuperChatMessage):
|
152 |
+
"""
|
153 |
+
醒目留言
|
154 |
+
"""
|
155 |
+
|
156 |
+
def _on_super_chat_delete(
|
157 |
+
self, client: ws_base.WebSocketClientBase, message: web_models.SuperChatDeleteMessage
|
158 |
+
):
|
159 |
+
"""
|
160 |
+
删除醒目留言
|
161 |
+
"""
|
162 |
+
|
163 |
+
#
|
164 |
+
# 开放平台消息
|
165 |
+
#
|
166 |
+
|
167 |
+
def _on_open_live_danmaku(self, client: ws_base.WebSocketClientBase, message: open_models.DanmakuMessage):
|
168 |
+
"""
|
169 |
+
收到弹幕
|
170 |
+
"""
|
171 |
+
|
172 |
+
def _on_open_live_gift(self, client: ws_base.WebSocketClientBase, message: open_models.GiftMessage):
|
173 |
+
"""
|
174 |
+
收到礼物
|
175 |
+
"""
|
176 |
+
|
177 |
+
def _on_open_live_buy_guard(self, client: ws_base.WebSocketClientBase, message: open_models.GuardBuyMessage):
|
178 |
+
"""
|
179 |
+
有人上舰
|
180 |
+
"""
|
181 |
+
|
182 |
+
def _on_open_live_super_chat(
|
183 |
+
self, client: ws_base.WebSocketClientBase, message: open_models.SuperChatMessage
|
184 |
+
):
|
185 |
+
"""
|
186 |
+
醒目留言
|
187 |
+
"""
|
188 |
+
|
189 |
+
def _on_open_live_super_chat_delete(
|
190 |
+
self, client: ws_base.WebSocketClientBase, message: open_models.SuperChatDeleteMessage
|
191 |
+
):
|
192 |
+
"""
|
193 |
+
删除醒目留言
|
194 |
+
"""
|
195 |
+
|
196 |
+
def _on_open_live_like(self, client: ws_base.WebSocketClientBase, message: open_models.LikeMessage):
|
197 |
+
"""
|
198 |
+
点赞
|
199 |
+
"""
|
blivedm/models/__init__.py
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
# -*- coding: utf-8 -*-
|
blivedm/models/open_live.py
ADDED
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# -*- coding: utf-8 -*-
|
2 |
+
import dataclasses
|
3 |
+
from typing import *
|
4 |
+
|
5 |
+
__all__ = (
|
6 |
+
'DanmakuMessage',
|
7 |
+
'GiftMessage',
|
8 |
+
'GuardBuyMessage',
|
9 |
+
'SuperChatMessage',
|
10 |
+
'SuperChatDeleteMessage',
|
11 |
+
'LikeMessage',
|
12 |
+
)
|
13 |
+
|
14 |
+
# 注释都是复制自官方文档的,看不懂的话问B站
|
15 |
+
# https://open-live.bilibili.com/document/f9ce25be-312e-1f4a-85fd-fef21f1637f8
|
16 |
+
|
17 |
+
|
18 |
+
@dataclasses.dataclass
|
19 |
+
class DanmakuMessage:
|
20 |
+
"""
|
21 |
+
弹幕消息
|
22 |
+
"""
|
23 |
+
|
24 |
+
uname: str = ''
|
25 |
+
"""用户昵称"""
|
26 |
+
open_id: str = ''
|
27 |
+
"""用户唯一标识"""
|
28 |
+
uface: str = ''
|
29 |
+
"""用户头像"""
|
30 |
+
timestamp: int = 0
|
31 |
+
"""弹幕发送时间秒级时间戳"""
|
32 |
+
room_id: int = 0
|
33 |
+
"""弹幕接收的直播间"""
|
34 |
+
msg: str = ''
|
35 |
+
"""弹幕内容"""
|
36 |
+
msg_id: str = ''
|
37 |
+
"""消息唯一id"""
|
38 |
+
guard_level: int = 0
|
39 |
+
"""对应房间大航海等级"""
|
40 |
+
fans_medal_wearing_status: bool = False
|
41 |
+
"""该房间粉丝勋章佩戴情况"""
|
42 |
+
fans_medal_name: str = ''
|
43 |
+
"""粉丝勋章名"""
|
44 |
+
fans_medal_level: int = 0
|
45 |
+
"""对应房间勋章信息"""
|
46 |
+
emoji_img_url: str = ''
|
47 |
+
"""表情包图片地址"""
|
48 |
+
dm_type: int = 0
|
49 |
+
"""弹幕类型 0:普通弹幕 1:表情包弹幕"""
|
50 |
+
|
51 |
+
@classmethod
|
52 |
+
def from_command(cls, data: dict):
|
53 |
+
return cls(
|
54 |
+
uname=data['uname'],
|
55 |
+
open_id=data['open_id'],
|
56 |
+
uface=data['uface'],
|
57 |
+
timestamp=data['timestamp'],
|
58 |
+
room_id=data['room_id'],
|
59 |
+
msg=data['msg'],
|
60 |
+
msg_id=data['msg_id'],
|
61 |
+
guard_level=data['guard_level'],
|
62 |
+
fans_medal_wearing_status=data['fans_medal_wearing_status'],
|
63 |
+
fans_medal_name=data['fans_medal_name'],
|
64 |
+
fans_medal_level=data['fans_medal_level'],
|
65 |
+
emoji_img_url=data['emoji_img_url'],
|
66 |
+
dm_type=data['dm_type'],
|
67 |
+
)
|
68 |
+
|
69 |
+
|
70 |
+
@dataclasses.dataclass
|
71 |
+
class AnchorInfo:
|
72 |
+
"""
|
73 |
+
主播信息
|
74 |
+
"""
|
75 |
+
|
76 |
+
uid: int = 0
|
77 |
+
"""收礼主播uid"""
|
78 |
+
open_id: str = ''
|
79 |
+
"""收礼主播唯一标识"""
|
80 |
+
uname: str = ''
|
81 |
+
"""收礼主播昵称"""
|
82 |
+
uface: str = ''
|
83 |
+
"""收礼主播头像"""
|
84 |
+
|
85 |
+
@classmethod
|
86 |
+
def from_dict(cls, data: dict):
|
87 |
+
return cls(
|
88 |
+
uid=data['uid'],
|
89 |
+
open_id=data['open_id'],
|
90 |
+
uname=data['uname'],
|
91 |
+
uface=data['uface'],
|
92 |
+
)
|
93 |
+
|
94 |
+
|
95 |
+
@dataclasses.dataclass
|
96 |
+
class ComboInfo:
|
97 |
+
"""
|
98 |
+
连击信息
|
99 |
+
"""
|
100 |
+
|
101 |
+
combo_base_num: int = 0
|
102 |
+
"""每次连击赠送的道具数量"""
|
103 |
+
combo_count: int = 0
|
104 |
+
"""连击次数"""
|
105 |
+
combo_id: str = ''
|
106 |
+
"""连击id"""
|
107 |
+
combo_timeout: int = 0
|
108 |
+
"""连击有效期秒"""
|
109 |
+
|
110 |
+
@classmethod
|
111 |
+
def from_dict(cls, data: dict):
|
112 |
+
return cls(
|
113 |
+
combo_base_num=data['combo_base_num'],
|
114 |
+
combo_count=data['combo_count'],
|
115 |
+
combo_id=data['combo_id'],
|
116 |
+
combo_timeout=data['combo_timeout'],
|
117 |
+
)
|
118 |
+
|
119 |
+
|
120 |
+
@dataclasses.dataclass
|
121 |
+
class GiftMessage:
|
122 |
+
"""
|
123 |
+
礼物消息
|
124 |
+
"""
|
125 |
+
|
126 |
+
room_id: int = 0
|
127 |
+
"""房间号"""
|
128 |
+
open_id: str = ''
|
129 |
+
"""用户唯一标识"""
|
130 |
+
uname: str = ''
|
131 |
+
"""送礼用户昵称"""
|
132 |
+
uface: str = ''
|
133 |
+
"""送礼用户头像"""
|
134 |
+
gift_id: int = 0
|
135 |
+
"""道具id(盲盒:爆出道具id)"""
|
136 |
+
gift_name: str = ''
|
137 |
+
"""道具名(盲盒:爆出道具名)"""
|
138 |
+
gift_num: int = 0
|
139 |
+
"""赠送道具数量"""
|
140 |
+
price: int = 0
|
141 |
+
"""礼物爆出单价,(1000 = 1元 = 10电池),盲盒:爆出道具的价值"""
|
142 |
+
paid: bool = False
|
143 |
+
"""是否是付费道具"""
|
144 |
+
fans_medal_level: int = 0
|
145 |
+
"""实际送礼人的勋章信息"""
|
146 |
+
fans_medal_name: str = ''
|
147 |
+
"""粉丝勋章名"""
|
148 |
+
fans_medal_wearing_status: bool = False
|
149 |
+
"""该房间粉丝勋章佩戴情况"""
|
150 |
+
guard_level: int = 0
|
151 |
+
"""大航海等级"""
|
152 |
+
timestamp: int = 0
|
153 |
+
"""收礼时间秒级时间戳"""
|
154 |
+
anchor_info: AnchorInfo = dataclasses.field(default_factory=AnchorInfo)
|
155 |
+
"""主播信息"""
|
156 |
+
msg_id: str = ''
|
157 |
+
"""消息唯一id"""
|
158 |
+
gift_icon: str = ''
|
159 |
+
"""道具icon"""
|
160 |
+
combo_gift: bool = False
|
161 |
+
"""是否是combo道具"""
|
162 |
+
combo_info: ComboInfo = dataclasses.field(default_factory=ComboInfo)
|
163 |
+
"""连击信息"""
|
164 |
+
|
165 |
+
@classmethod
|
166 |
+
def from_command(cls, data: dict):
|
167 |
+
combo_info = data.get('combo_info', None)
|
168 |
+
if combo_info is None:
|
169 |
+
combo_info = ComboInfo()
|
170 |
+
else:
|
171 |
+
combo_info = ComboInfo.from_dict(combo_info)
|
172 |
+
|
173 |
+
return cls(
|
174 |
+
room_id=data['room_id'],
|
175 |
+
open_id=data['open_id'],
|
176 |
+
uname=data['uname'],
|
177 |
+
uface=data['uface'],
|
178 |
+
gift_id=data['gift_id'],
|
179 |
+
gift_name=data['gift_name'],
|
180 |
+
gift_num=data['gift_num'],
|
181 |
+
price=data['price'],
|
182 |
+
paid=data['paid'],
|
183 |
+
fans_medal_level=data['fans_medal_level'],
|
184 |
+
fans_medal_name=data['fans_medal_name'],
|
185 |
+
fans_medal_wearing_status=data['fans_medal_wearing_status'],
|
186 |
+
guard_level=data['guard_level'],
|
187 |
+
timestamp=data['timestamp'],
|
188 |
+
anchor_info=AnchorInfo.from_dict(data['anchor_info']),
|
189 |
+
msg_id=data['msg_id'],
|
190 |
+
gift_icon=data['gift_icon'],
|
191 |
+
combo_gift=data.get('combo_gift', False), # 官方的调试工具没发这个字段
|
192 |
+
combo_info=combo_info, # 官方的调试工具没发这个字段
|
193 |
+
)
|
194 |
+
|
195 |
+
|
196 |
+
@dataclasses.dataclass
|
197 |
+
class UserInfo:
|
198 |
+
"""
|
199 |
+
用户信息
|
200 |
+
"""
|
201 |
+
|
202 |
+
open_id: str = ''
|
203 |
+
"""用户唯一标识"""
|
204 |
+
uname: str = ''
|
205 |
+
"""用户昵称"""
|
206 |
+
uface: str = ''
|
207 |
+
"""用户头像"""
|
208 |
+
|
209 |
+
@classmethod
|
210 |
+
def from_dict(cls, data: dict):
|
211 |
+
return cls(
|
212 |
+
open_id=data['open_id'],
|
213 |
+
uname=data['uname'],
|
214 |
+
uface=data['uface'],
|
215 |
+
)
|
216 |
+
|
217 |
+
|
218 |
+
@dataclasses.dataclass
|
219 |
+
class GuardBuyMessage:
|
220 |
+
"""
|
221 |
+
上舰消息
|
222 |
+
"""
|
223 |
+
|
224 |
+
user_info: UserInfo = dataclasses.field(default_factory=UserInfo)
|
225 |
+
"""用户信息"""
|
226 |
+
guard_level: int = 0
|
227 |
+
"""大航海等级"""
|
228 |
+
guard_num: int = 0
|
229 |
+
"""大航海数量"""
|
230 |
+
guard_unit: str = ''
|
231 |
+
"""大航海单位"""
|
232 |
+
price: int = 0
|
233 |
+
"""大航海金瓜子"""
|
234 |
+
fans_medal_level: int = 0
|
235 |
+
"""粉丝勋章等级"""
|
236 |
+
fans_medal_name: str = ''
|
237 |
+
"""粉丝勋章名"""
|
238 |
+
fans_medal_wearing_status: bool = False
|
239 |
+
"""该房间粉丝勋章佩戴情况"""
|
240 |
+
room_id: int = 0
|
241 |
+
"""房间号"""
|
242 |
+
msg_id: str = ''
|
243 |
+
"""消息唯一id"""
|
244 |
+
timestamp: int = 0
|
245 |
+
"""上舰时间秒级时间戳"""
|
246 |
+
|
247 |
+
@classmethod
|
248 |
+
def from_command(cls, data: dict):
|
249 |
+
return cls(
|
250 |
+
user_info=UserInfo.from_dict(data['user_info']),
|
251 |
+
guard_level=data['guard_level'],
|
252 |
+
guard_num=data['guard_num'],
|
253 |
+
guard_unit=data['guard_unit'],
|
254 |
+
price=data['price'],
|
255 |
+
fans_medal_level=data['fans_medal_level'],
|
256 |
+
fans_medal_name=data['fans_medal_name'],
|
257 |
+
fans_medal_wearing_status=data['fans_medal_wearing_status'],
|
258 |
+
room_id=data['room_id'],
|
259 |
+
msg_id=data['msg_id'],
|
260 |
+
timestamp=data['timestamp'],
|
261 |
+
)
|
262 |
+
|
263 |
+
|
264 |
+
@dataclasses.dataclass
|
265 |
+
class SuperChatMessage:
|
266 |
+
"""
|
267 |
+
醒目留言消息
|
268 |
+
"""
|
269 |
+
|
270 |
+
room_id: int = 0
|
271 |
+
"""直播间id"""
|
272 |
+
open_id: str = ''
|
273 |
+
"""用户唯一标识"""
|
274 |
+
uname: str = ''
|
275 |
+
"""购买的用户昵称"""
|
276 |
+
uface: str = ''
|
277 |
+
"""购买用户头像"""
|
278 |
+
message_id: int = 0
|
279 |
+
"""留言id(风控场景下撤回留言需要)"""
|
280 |
+
message: str = ''
|
281 |
+
"""留言内容"""
|
282 |
+
rmb: int = 0
|
283 |
+
"""支付金额(元)"""
|
284 |
+
timestamp: int = 0
|
285 |
+
"""赠送时间秒级"""
|
286 |
+
start_time: int = 0
|
287 |
+
"""生效开始时间"""
|
288 |
+
end_time: int = 0
|
289 |
+
"""生效结束时间"""
|
290 |
+
guard_level: int = 0
|
291 |
+
"""对应房间大航海等级"""
|
292 |
+
fans_medal_level: int = 0
|
293 |
+
"""对应房间勋章信息"""
|
294 |
+
fans_medal_name: str = ''
|
295 |
+
"""对应房间勋章名字"""
|
296 |
+
fans_medal_wearing_status: bool = False
|
297 |
+
"""该房间粉丝勋章佩戴情况"""
|
298 |
+
msg_id: str = ''
|
299 |
+
"""消息唯一id"""
|
300 |
+
|
301 |
+
@classmethod
|
302 |
+
def from_command(cls, data: dict):
|
303 |
+
return cls(
|
304 |
+
room_id=data['room_id'],
|
305 |
+
open_id=data['open_id'],
|
306 |
+
uname=data['uname'],
|
307 |
+
uface=data['uface'],
|
308 |
+
message_id=data['message_id'],
|
309 |
+
message=data['message'],
|
310 |
+
rmb=data['rmb'],
|
311 |
+
timestamp=data['timestamp'],
|
312 |
+
start_time=data['start_time'],
|
313 |
+
end_time=data['end_time'],
|
314 |
+
guard_level=data['guard_level'],
|
315 |
+
fans_medal_level=data['fans_medal_level'],
|
316 |
+
fans_medal_name=data['fans_medal_name'],
|
317 |
+
fans_medal_wearing_status=data['fans_medal_wearing_status'],
|
318 |
+
msg_id=data['msg_id'],
|
319 |
+
)
|
320 |
+
|
321 |
+
|
322 |
+
@dataclasses.dataclass
|
323 |
+
class SuperChatDeleteMessage:
|
324 |
+
"""
|
325 |
+
删除醒目留言消息
|
326 |
+
"""
|
327 |
+
|
328 |
+
room_id: int = 0
|
329 |
+
"""直播间id"""
|
330 |
+
message_ids: List[int] = dataclasses.field(default_factory=list)
|
331 |
+
"""留言id"""
|
332 |
+
msg_id: str = ''
|
333 |
+
"""消息唯一id"""
|
334 |
+
|
335 |
+
@classmethod
|
336 |
+
def from_command(cls, data: dict):
|
337 |
+
return cls(
|
338 |
+
room_id=data['room_id'],
|
339 |
+
message_ids=data['message_ids'],
|
340 |
+
msg_id=data['msg_id'],
|
341 |
+
)
|
342 |
+
|
343 |
+
|
344 |
+
@dataclasses.dataclass
|
345 |
+
class LikeMessage:
|
346 |
+
"""
|
347 |
+
点赞消息
|
348 |
+
|
349 |
+
请注意:用户端每分钟触发若干次的情况下只会推送一次该消息
|
350 |
+
"""
|
351 |
+
|
352 |
+
uname: str = ''
|
353 |
+
"""用户昵称"""
|
354 |
+
open_id: str = ''
|
355 |
+
"""用户唯一标识"""
|
356 |
+
uface: str = ''
|
357 |
+
"""用户头像"""
|
358 |
+
timestamp: int = 0
|
359 |
+
"""时间秒级时间戳"""
|
360 |
+
room_id: int = 0
|
361 |
+
"""发生的直播间"""
|
362 |
+
like_text: str = ''
|
363 |
+
"""点赞文案(“xxx点赞了”)"""
|
364 |
+
like_count: int = 0
|
365 |
+
"""对单个用户最近2秒的点赞次数聚合"""
|
366 |
+
fans_medal_wearing_status: bool = False
|
367 |
+
"""该房间粉丝勋章佩戴情况"""
|
368 |
+
fans_medal_name: str = ''
|
369 |
+
"""粉丝勋章名"""
|
370 |
+
fans_medal_level: int = 0
|
371 |
+
"""对应房间勋章信息"""
|
372 |
+
msg_id: str = '' # 官方文档表格里没列出这个字段,但是参考JSON里面有
|
373 |
+
"""消息唯一id"""
|
374 |
+
# 还有个guard_level,但官方文档没有出现这个字段,就不添加了
|
375 |
+
|
376 |
+
@classmethod
|
377 |
+
def from_command(cls, data: dict):
|
378 |
+
return cls(
|
379 |
+
uname=data['uname'],
|
380 |
+
open_id=data['open_id'],
|
381 |
+
uface=data['uface'],
|
382 |
+
timestamp=data['timestamp'],
|
383 |
+
room_id=data['room_id'],
|
384 |
+
like_text=data['like_text'],
|
385 |
+
like_count=data['like_count'],
|
386 |
+
fans_medal_wearing_status=data['fans_medal_wearing_status'],
|
387 |
+
fans_medal_name=data['fans_medal_name'],
|
388 |
+
fans_medal_level=data['fans_medal_level'],
|
389 |
+
msg_id=data.get('msg_id', ''), # 官方文档表格里没列出这个字段,但是参考JSON里面有
|
390 |
+
)
|
blivedm/models/web.py
ADDED
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# -*- coding: utf-8 -*-
|
2 |
+
import dataclasses
|
3 |
+
import json
|
4 |
+
from typing import *
|
5 |
+
|
6 |
+
__all__ = (
|
7 |
+
'HeartbeatMessage',
|
8 |
+
'DanmakuMessage',
|
9 |
+
'GiftMessage',
|
10 |
+
'GuardBuyMessage',
|
11 |
+
'SuperChatMessage',
|
12 |
+
'SuperChatDeleteMessage',
|
13 |
+
)
|
14 |
+
|
15 |
+
|
16 |
+
@dataclasses.dataclass
|
17 |
+
class HeartbeatMessage:
|
18 |
+
"""
|
19 |
+
心跳消息
|
20 |
+
"""
|
21 |
+
|
22 |
+
popularity: int = 0
|
23 |
+
"""人气值,已废弃"""
|
24 |
+
|
25 |
+
@classmethod
|
26 |
+
def from_command(cls, data: dict):
|
27 |
+
return cls(
|
28 |
+
popularity=data['popularity'],
|
29 |
+
)
|
30 |
+
|
31 |
+
|
32 |
+
@dataclasses.dataclass
|
33 |
+
class DanmakuMessage:
|
34 |
+
"""
|
35 |
+
弹幕消息
|
36 |
+
"""
|
37 |
+
|
38 |
+
mode: int = 0
|
39 |
+
"""弹幕显示模式(滚动、顶部、底部)"""
|
40 |
+
font_size: int = 0
|
41 |
+
"""字体尺寸"""
|
42 |
+
color: int = 0
|
43 |
+
"""颜色"""
|
44 |
+
timestamp: int = 0
|
45 |
+
"""时间戳(毫秒)"""
|
46 |
+
rnd: int = 0
|
47 |
+
"""随机数,前端叫作弹幕ID,可能是去重用的"""
|
48 |
+
uid_crc32: str = ''
|
49 |
+
"""用户ID文本的CRC32"""
|
50 |
+
msg_type: int = 0
|
51 |
+
"""是否礼物弹幕(节奏风暴)"""
|
52 |
+
bubble: int = 0
|
53 |
+
"""右侧评论栏气泡"""
|
54 |
+
dm_type: int = 0
|
55 |
+
"""弹幕类型,0文本,1表情,2语音"""
|
56 |
+
emoticon_options: Union[dict, str] = ''
|
57 |
+
"""表情参数"""
|
58 |
+
voice_config: Union[dict, str] = ''
|
59 |
+
"""语音参数"""
|
60 |
+
mode_info: dict = dataclasses.field(default_factory=dict)
|
61 |
+
"""一些附加参数"""
|
62 |
+
|
63 |
+
msg: str = ''
|
64 |
+
"""弹幕内容"""
|
65 |
+
|
66 |
+
uid: int = 0
|
67 |
+
"""用户ID"""
|
68 |
+
uname: str = ''
|
69 |
+
"""用户名"""
|
70 |
+
admin: int = 0
|
71 |
+
"""是否房管"""
|
72 |
+
vip: int = 0
|
73 |
+
"""是否月费老爷"""
|
74 |
+
svip: int = 0
|
75 |
+
"""是否年费老爷"""
|
76 |
+
urank: int = 0
|
77 |
+
"""用户身份,用来判断是否正式会员,猜测非正式会员为5000,正式会员为10000"""
|
78 |
+
mobile_verify: int = 0
|
79 |
+
"""是否绑定手机"""
|
80 |
+
uname_color: str = ''
|
81 |
+
"""用户名颜色"""
|
82 |
+
|
83 |
+
medal_level: str = ''
|
84 |
+
"""勋章等级"""
|
85 |
+
medal_name: str = ''
|
86 |
+
"""勋章名"""
|
87 |
+
runame: str = ''
|
88 |
+
"""勋章房间主播名"""
|
89 |
+
medal_room_id: int = 0
|
90 |
+
"""勋章房间ID"""
|
91 |
+
mcolor: int = 0
|
92 |
+
"""勋章颜色"""
|
93 |
+
special_medal: str = ''
|
94 |
+
"""特殊勋章"""
|
95 |
+
|
96 |
+
user_level: int = 0
|
97 |
+
"""用户等级"""
|
98 |
+
ulevel_color: int = 0
|
99 |
+
"""用户等级颜色"""
|
100 |
+
ulevel_rank: str = ''
|
101 |
+
"""用户等级排名,>50000时为'>50000'"""
|
102 |
+
|
103 |
+
old_title: str = ''
|
104 |
+
"""旧头衔"""
|
105 |
+
title: str = ''
|
106 |
+
"""头衔"""
|
107 |
+
|
108 |
+
privilege_type: int = 0
|
109 |
+
"""舰队类型,0非舰队,1总督,2提督,3舰长"""
|
110 |
+
|
111 |
+
@classmethod
|
112 |
+
def from_command(cls, info: list):
|
113 |
+
if len(info[3]) != 0:
|
114 |
+
medal_level = info[3][0]
|
115 |
+
medal_name = info[3][1]
|
116 |
+
runame = info[3][2]
|
117 |
+
room_id = info[3][3]
|
118 |
+
mcolor = info[3][4]
|
119 |
+
special_medal = info[3][5]
|
120 |
+
else:
|
121 |
+
medal_level = 0
|
122 |
+
medal_name = ''
|
123 |
+
runame = ''
|
124 |
+
room_id = 0
|
125 |
+
mcolor = 0
|
126 |
+
special_medal = 0
|
127 |
+
|
128 |
+
if len(info[5]) != 0:
|
129 |
+
old_title = info[5][0]
|
130 |
+
title = info[5][1]
|
131 |
+
else:
|
132 |
+
old_title = ''
|
133 |
+
title = ''
|
134 |
+
|
135 |
+
return cls(
|
136 |
+
mode=info[0][1],
|
137 |
+
font_size=info[0][2],
|
138 |
+
color=info[0][3],
|
139 |
+
timestamp=info[0][4],
|
140 |
+
rnd=info[0][5],
|
141 |
+
uid_crc32=info[0][7],
|
142 |
+
msg_type=info[0][9],
|
143 |
+
bubble=info[0][10],
|
144 |
+
dm_type=info[0][12],
|
145 |
+
emoticon_options=info[0][13],
|
146 |
+
voice_config=info[0][14],
|
147 |
+
mode_info=info[0][15],
|
148 |
+
|
149 |
+
msg=info[1],
|
150 |
+
|
151 |
+
uid=info[2][0],
|
152 |
+
uname=info[2][1],
|
153 |
+
admin=info[2][2],
|
154 |
+
vip=info[2][3],
|
155 |
+
svip=info[2][4],
|
156 |
+
urank=info[2][5],
|
157 |
+
mobile_verify=info[2][6],
|
158 |
+
uname_color=info[2][7],
|
159 |
+
|
160 |
+
medal_level=medal_level,
|
161 |
+
medal_name=medal_name,
|
162 |
+
runame=runame,
|
163 |
+
medal_room_id=room_id,
|
164 |
+
mcolor=mcolor,
|
165 |
+
special_medal=special_medal,
|
166 |
+
|
167 |
+
user_level=info[4][0],
|
168 |
+
ulevel_color=info[4][2],
|
169 |
+
ulevel_rank=info[4][3],
|
170 |
+
|
171 |
+
old_title=old_title,
|
172 |
+
title=title,
|
173 |
+
|
174 |
+
privilege_type=info[7],
|
175 |
+
)
|
176 |
+
|
177 |
+
@property
|
178 |
+
def emoticon_options_dict(self) -> dict:
|
179 |
+
"""
|
180 |
+
示例:
|
181 |
+
{'bulge_display': 0, 'emoticon_unique': 'official_13', 'height': 60, 'in_player_area': 1, 'is_dynamic': 1,
|
182 |
+
'url': 'https://i0.hdslb.com/bfs/live/a98e35996545509188fe4d24bd1a56518ea5af48.png', 'width': 183}
|
183 |
+
"""
|
184 |
+
if isinstance(self.emoticon_options, dict):
|
185 |
+
return self.emoticon_options
|
186 |
+
try:
|
187 |
+
return json.loads(self.emoticon_options)
|
188 |
+
except (json.JSONDecodeError, TypeError):
|
189 |
+
return {}
|
190 |
+
|
191 |
+
@property
|
192 |
+
def voice_config_dict(self) -> dict:
|
193 |
+
"""
|
194 |
+
示例:
|
195 |
+
{'voice_url': 'https%3A%2F%2Fboss.hdslb.com%2Flive-dm-voice%2Fb5b26e48b556915cbf3312a59d3bb2561627725945.wav
|
196 |
+
%3FX-Amz-Algorithm%3DAWS4-HMAC-SHA256%26X-Amz-Credential%3D2663ba902868f12f%252F20210731%252Fshjd%252Fs3%25
|
197 |
+
2Faws4_request%26X-Amz-Date%3D20210731T100545Z%26X-Amz-Expires%3D600000%26X-Amz-SignedHeaders%3Dhost%26
|
198 |
+
X-Amz-Signature%3D114e7cb5ac91c72e231c26d8ca211e53914722f36309b861a6409ffb20f07ab8',
|
199 |
+
'file_format': 'wav', 'text': '汤,下午好。', 'file_duration': 1}
|
200 |
+
"""
|
201 |
+
if isinstance(self.voice_config, dict):
|
202 |
+
return self.voice_config
|
203 |
+
try:
|
204 |
+
return json.loads(self.voice_config)
|
205 |
+
except (json.JSONDecodeError, TypeError):
|
206 |
+
return {}
|
207 |
+
|
208 |
+
|
209 |
+
@dataclasses.dataclass
|
210 |
+
class GiftMessage:
|
211 |
+
"""
|
212 |
+
礼物消息
|
213 |
+
"""
|
214 |
+
|
215 |
+
gift_name: str = ''
|
216 |
+
"""礼物名"""
|
217 |
+
num: int = 0
|
218 |
+
"""数量"""
|
219 |
+
uname: str = ''
|
220 |
+
"""用户名"""
|
221 |
+
face: str = ''
|
222 |
+
"""用户头像URL"""
|
223 |
+
guard_level: int = 0
|
224 |
+
"""舰队等级,0非舰队,1总督,2提督,3舰长"""
|
225 |
+
uid: int = 0
|
226 |
+
"""用户ID"""
|
227 |
+
timestamp: int = 0
|
228 |
+
"""时间戳"""
|
229 |
+
gift_id: int = 0
|
230 |
+
"""礼物ID"""
|
231 |
+
gift_type: int = 0
|
232 |
+
"""礼物类型(未知)"""
|
233 |
+
action: str = ''
|
234 |
+
"""目前遇到的有'喂食'、'赠送'"""
|
235 |
+
price: int = 0
|
236 |
+
"""礼物单价瓜子数"""
|
237 |
+
rnd: str = ''
|
238 |
+
"""随机数,可能是去重用的。有时是时间戳+去重ID,有时是UUID"""
|
239 |
+
coin_type: str = ''
|
240 |
+
"""瓜子类型,'silver'或'gold',1000金瓜子 = 1元"""
|
241 |
+
total_coin: int = 0
|
242 |
+
"""总瓜子数"""
|
243 |
+
tid: str = ''
|
244 |
+
"""可能是事务ID,有时和rnd相同"""
|
245 |
+
|
246 |
+
@classmethod
|
247 |
+
def from_command(cls, data: dict):
|
248 |
+
return cls(
|
249 |
+
gift_name=data['giftName'],
|
250 |
+
num=data['num'],
|
251 |
+
uname=data['uname'],
|
252 |
+
face=data['face'],
|
253 |
+
guard_level=data['guard_level'],
|
254 |
+
uid=data['uid'],
|
255 |
+
timestamp=data['timestamp'],
|
256 |
+
gift_id=data['giftId'],
|
257 |
+
gift_type=data['giftType'],
|
258 |
+
action=data['action'],
|
259 |
+
price=data['price'],
|
260 |
+
rnd=data['rnd'],
|
261 |
+
coin_type=data['coin_type'],
|
262 |
+
total_coin=data['total_coin'],
|
263 |
+
tid=data['tid'],
|
264 |
+
)
|
265 |
+
|
266 |
+
|
267 |
+
@dataclasses.dataclass
|
268 |
+
class GuardBuyMessage:
|
269 |
+
"""
|
270 |
+
上舰消息
|
271 |
+
"""
|
272 |
+
|
273 |
+
uid: int = 0
|
274 |
+
"""用户ID"""
|
275 |
+
username: str = ''
|
276 |
+
"""用户名"""
|
277 |
+
guard_level: int = 0
|
278 |
+
"""舰队等级,0非舰队,1总督,2提督,3舰长"""
|
279 |
+
num: int = 0
|
280 |
+
"""数量"""
|
281 |
+
price: int = 0
|
282 |
+
"""单价金瓜子数"""
|
283 |
+
gift_id: int = 0
|
284 |
+
"""礼物ID"""
|
285 |
+
gift_name: str = ''
|
286 |
+
"""礼物名"""
|
287 |
+
start_time: int = 0
|
288 |
+
"""开始时间戳,和结束时间戳相同"""
|
289 |
+
end_time: int = 0
|
290 |
+
"""结束时间戳,和开始时间戳相同"""
|
291 |
+
|
292 |
+
@classmethod
|
293 |
+
def from_command(cls, data: dict):
|
294 |
+
return cls(
|
295 |
+
uid=data['uid'],
|
296 |
+
username=data['username'],
|
297 |
+
guard_level=data['guard_level'],
|
298 |
+
num=data['num'],
|
299 |
+
price=data['price'],
|
300 |
+
gift_id=data['gift_id'],
|
301 |
+
gift_name=data['gift_name'],
|
302 |
+
start_time=data['start_time'],
|
303 |
+
end_time=data['end_time'],
|
304 |
+
)
|
305 |
+
|
306 |
+
|
307 |
+
@dataclasses.dataclass
|
308 |
+
class SuperChatMessage:
|
309 |
+
"""
|
310 |
+
醒目留言消息
|
311 |
+
"""
|
312 |
+
|
313 |
+
price: int = 0
|
314 |
+
"""价格(人民币)"""
|
315 |
+
message: str = ''
|
316 |
+
"""消息"""
|
317 |
+
message_trans: str = ''
|
318 |
+
"""消息日文翻译(目前只出现在SUPER_CHAT_MESSAGE_JPN)"""
|
319 |
+
start_time: int = 0
|
320 |
+
"""开始时间戳"""
|
321 |
+
end_time: int = 0
|
322 |
+
"""结束时间戳"""
|
323 |
+
time: int = 0
|
324 |
+
"""剩余时间(约等于 结束时间戳 - 开始时间戳)"""
|
325 |
+
id: int = 0
|
326 |
+
"""醒目留言ID,删除时用"""
|
327 |
+
gift_id: int = 0
|
328 |
+
"""礼物ID"""
|
329 |
+
gift_name: str = ''
|
330 |
+
"""礼物名"""
|
331 |
+
uid: int = 0
|
332 |
+
"""用户ID"""
|
333 |
+
uname: str = ''
|
334 |
+
"""用户名"""
|
335 |
+
face: str = ''
|
336 |
+
"""用户头像URL"""
|
337 |
+
guard_level: int = 0
|
338 |
+
"""舰队等级,0非舰队,1总督,2提督,3舰长"""
|
339 |
+
user_level: int = 0
|
340 |
+
"""用户等级"""
|
341 |
+
background_bottom_color: str = ''
|
342 |
+
"""底部背景色,'#rrggbb'"""
|
343 |
+
background_color: str = ''
|
344 |
+
"""背景色,'#rrggbb'"""
|
345 |
+
background_icon: str = ''
|
346 |
+
"""背景图标"""
|
347 |
+
background_image: str = ''
|
348 |
+
"""背景图URL"""
|
349 |
+
background_price_color: str = ''
|
350 |
+
"""背景价格颜色,'#rrggbb'"""
|
351 |
+
|
352 |
+
@classmethod
|
353 |
+
def from_command(cls, data: dict):
|
354 |
+
return cls(
|
355 |
+
price=data['price'],
|
356 |
+
message=data['message'],
|
357 |
+
message_trans=data['message_trans'],
|
358 |
+
start_time=data['start_time'],
|
359 |
+
end_time=data['end_time'],
|
360 |
+
time=data['time'],
|
361 |
+
id=data['id'],
|
362 |
+
gift_id=data['gift']['gift_id'],
|
363 |
+
gift_name=data['gift']['gift_name'],
|
364 |
+
uid=data['uid'],
|
365 |
+
uname=data['user_info']['uname'],
|
366 |
+
face=data['user_info']['face'],
|
367 |
+
guard_level=data['user_info']['guard_level'],
|
368 |
+
user_level=data['user_info']['user_level'],
|
369 |
+
background_bottom_color=data['background_bottom_color'],
|
370 |
+
background_color=data['background_color'],
|
371 |
+
background_icon=data['background_icon'],
|
372 |
+
background_image=data['background_image'],
|
373 |
+
background_price_color=data['background_price_color'],
|
374 |
+
)
|
375 |
+
|
376 |
+
|
377 |
+
@dataclasses.dataclass
|
378 |
+
class SuperChatDeleteMessage:
|
379 |
+
"""
|
380 |
+
删除醒目留言消息
|
381 |
+
"""
|
382 |
+
|
383 |
+
ids: List[int] = dataclasses.field(default_factory=list)
|
384 |
+
"""醒目留言ID数组"""
|
385 |
+
|
386 |
+
@classmethod
|
387 |
+
def from_command(cls, data: dict):
|
388 |
+
return cls(
|
389 |
+
ids=data['ids'],
|
390 |
+
)
|
blivedm/utils.py
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# -*- coding: utf-8 -*-
|
2 |
+
USER_AGENT = (
|
3 |
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36'
|
4 |
+
)
|
5 |
+
|
6 |
+
|
7 |
+
def make_constant_retry_policy(interval: float):
|
8 |
+
def get_interval(_retry_count: int, _total_retry_count: int):
|
9 |
+
return interval
|
10 |
+
return get_interval
|
11 |
+
|
12 |
+
|
13 |
+
def make_linear_retry_policy(start_interval: float, interval_step: float, max_interval: float):
|
14 |
+
def get_interval(retry_count: int, _total_retry_count: int):
|
15 |
+
return min(
|
16 |
+
start_interval + (retry_count - 1) * interval_step,
|
17 |
+
max_interval
|
18 |
+
)
|
19 |
+
return get_interval
|