blivedm/blivedm/client.py

510 lines
17 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
2019-02-20 00:25:14 +08:00
import asyncio
2021-12-12 21:54:07 +08:00
import collections
import enum
import json
2019-02-20 14:53:50 +08:00
import logging
2019-06-12 22:32:34 +08:00
import ssl as ssl_
import struct
2019-09-15 18:46:45 +08:00
import zlib
2019-06-06 21:50:51 +08:00
from typing import *
import aiohttp
2018-05-13 21:57:36 +08:00
2021-12-13 00:07:00 +08:00
from . import handlers
2021-12-12 21:54:07 +08:00
2021-12-12 19:28:12 +08:00
logger = logging.getLogger('blivedm')
2019-02-20 14:53:50 +08:00
2020-07-19 22:17:32 +08:00
ROOM_INIT_URL = 'https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom'
DANMAKU_SERVER_CONF_URL = 'https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo'
DEFAULT_DANMAKU_SERVER_LIST = [
{'host': 'broadcastlv.chat.bilibili.com', 'port': 2243, 'wss_port': 443, 'ws_port': 2244}
]
2018-05-13 21:57:36 +08:00
2019-04-22 19:47:05 +08:00
HEADER_STRUCT = struct.Struct('>I2H2I')
2021-12-12 21:54:07 +08:00
HeaderTuple = collections.namedtuple('HeaderTuple', ('pack_len', 'raw_header_size', 'ver', 'operation', 'seq_id'))
2021-01-31 13:11:20 +08:00
WS_BODY_PROTOCOL_VERSION_INFLATE = 0
WS_BODY_PROTOCOL_VERSION_NORMAL = 1
2019-09-15 18:46:45 +08:00
WS_BODY_PROTOCOL_VERSION_DEFLATE = 2
2019-04-22 19:47:05 +08:00
# go-common\app\service\main\broadcast\model\operation.go
2021-12-12 21:54:07 +08:00
class Operation(enum.IntEnum):
2019-04-22 19:47:05 +08:00
HANDSHAKE = 0
HANDSHAKE_REPLY = 1
HEARTBEAT = 2
HEARTBEAT_REPLY = 3
SEND_MSG = 4
SEND_MSG_REPLY = 5
DISCONNECT_REPLY = 6
AUTH = 7
2019-04-22 19:47:05 +08:00
AUTH_REPLY = 8
RAW = 9
PROTO_READY = 10
PROTO_FINISH = 11
CHANGE_ROOM = 12
CHANGE_ROOM_REPLY = 13
REGISTER = 14
REGISTER_REPLY = 15
UNREGISTER = 16
UNREGISTER_REPLY = 17
# B站业务自定义OP
# MinBusinessOp = 1000
# MaxBusinessOp = 10000
2018-05-13 21:57:36 +08:00
2019-09-15 18:46:45 +08:00
class InitError(Exception):
"""初始化失败"""
class BLiveClient:
2021-12-13 00:07:00 +08:00
"""
B站直播弹幕客户端负责连接房间
:param room_id: URL中的房间ID可以用短ID
:param uid: B站用户ID0表示未登录
:param session: cookie连接池
:param heartbeat_interval: 发送心跳包的间隔时间
:param ssl: True表示用默认的SSLContext验证False表示不验证也可以传入SSLContext
:param loop: 协程事件循环
"""
def __init__(
self,
room_id,
uid=0,
2021-12-15 00:09:07 +08:00
session: Optional[aiohttp.ClientSession] = None,
2021-12-13 00:07:00 +08:00
heartbeat_interval=30,
ssl: Union[bool, ssl_.SSLContext] = True,
2021-12-15 00:09:07 +08:00
loop: Optional[asyncio.BaseEventLoop] = None,
2019-03-23 23:58:02 +08:00
):
2021-12-15 00:09:07 +08:00
# 用来init_room的临时房间ID可以用短ID
2019-06-06 21:50:51 +08:00
self._tmp_room_id = room_id
2019-02-20 00:25:14 +08:00
self._uid = uid
if loop is not None:
self._loop = loop
elif session is not None:
2021-12-12 21:54:07 +08:00
self._loop = session.loop # noqa
else:
self._loop = asyncio.get_event_loop()
2019-02-20 00:25:14 +08:00
if session is None:
2021-03-28 17:47:23 +08:00
self._session = aiohttp.ClientSession(loop=self._loop, timeout=aiohttp.ClientTimeout(total=10))
2019-02-20 00:25:14 +08:00
self._own_session = True
else:
self._session = session
self._own_session = False
2021-12-12 21:54:07 +08:00
if self._session.loop is not self._loop: # noqa
2021-12-15 00:09:07 +08:00
raise RuntimeError('BLiveClient and session must use the same event loop')
2019-09-15 18:46:45 +08:00
self._heartbeat_interval = heartbeat_interval
2021-12-12 21:54:07 +08:00
self._ssl = ssl if ssl else ssl_._create_unverified_context() # noqa
2021-12-15 00:09:07 +08:00
# 消息处理器,可动态增删
2021-12-13 00:07:00 +08:00
self._handlers: List[handlers.HandlerInterface] = []
2021-12-15 00:09:07 +08:00
# 在调用init_room后初始化的字段
# 真实房间ID
self._room_id = None
# 房间短ID没有则为0
self._room_short_id = None
# 主播用户ID
self._room_owner_uid = None
# 弹幕服务器列表
# [{host: "tx-bj4-live-comet-04.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244}, ...]
self._host_server_list: Optional[List[dict]] = None
# 连接弹幕服务器用的token
self._host_server_token = None
# 在运行时初始化的字段
# websocket连接
self._websocket: Optional[aiohttp.ClientWebSocketResponse] = None
# 网络协程的future
self._network_future: Optional[asyncio.Future] = None
# 发心跳包定时器的handle
self._heartbeat_timer_handle: Optional[asyncio.TimerHandle] = None
@property
2021-12-15 00:09:07 +08:00
def is_running(self) -> bool:
"""
本客户端正在运行注意调用stop后还没完全停止也算正在运行
"""
return self._network_future is not None
2019-06-06 21:50:51 +08:00
@property
2021-12-15 00:09:07 +08:00
def room_id(self) -> Optional[int]:
2019-06-06 21:50:51 +08:00
"""
房间ID调用init_room后初始化
"""
return self._room_id
@property
2021-12-15 00:09:07 +08:00
def room_short_id(self) -> Optional[int]:
2019-06-06 21:50:51 +08:00
"""
房间短ID没有则为0调用init_room后初始化
"""
return self._room_short_id
@property
2021-12-15 00:09:07 +08:00
def room_owner_uid(self) -> Optional[int]:
2019-06-06 21:50:51 +08:00
"""
2021-12-15 00:09:07 +08:00
主播用户ID调用init_room后初始化
2019-06-06 21:50:51 +08:00
"""
return self._room_owner_uid
2021-12-13 00:07:00 +08:00
def add_handler(self, handler: 'handlers.HandlerInterface'):
2019-02-20 00:25:14 +08:00
"""
2021-12-13 00:07:00 +08:00
添加消息处理器
注意多个处理器是并发处理的不要依赖处理的顺序
消息处理器和接收消息运行在同一协程如果处理消息耗时太长会阻塞接收消息这种情况建议将消息推到队列让另一个协程处理
2021-12-15 00:09:07 +08:00
2021-12-13 00:07:00 +08:00
:param handler: 消息处理器
2019-02-20 00:25:14 +08:00
"""
2021-12-13 00:07:00 +08:00
if handler not in self._handlers:
self._handlers.append(handler)
def remove_handler(self, handler: 'handlers.HandlerInterface'):
"""
移除消息处理器
2021-12-15 00:09:07 +08:00
2021-12-13 00:07:00 +08:00
:param handler: 消息处理器
"""
try:
self._handlers.remove(handler)
except ValueError:
pass
2019-06-06 21:50:51 +08:00
def start(self):
"""
2021-12-15 00:09:07 +08:00
启动本客户端
"""
if self.is_running:
2021-12-15 00:09:07 +08:00
logger.warning('room %s 已经在运行中不能再次start', self.room_id)
return
self._network_future = asyncio.ensure_future(self._network_coroutine_wrapper(), loop=self._loop)
2019-06-12 22:32:34 +08:00
2019-06-06 21:50:51 +08:00
def stop(self):
"""
2021-12-15 00:09:07 +08:00
停止本客户端
"""
if not self.is_running:
2021-12-15 00:09:07 +08:00
logger.warning('room %s 已经停止不能再次stop', self.room_id)
return
self._network_future.cancel()
async def stop_and_close(self):
"""
停止本客户端并释放本客户端的资源调用后本客户端将不可用
2019-06-06 21:50:51 +08:00
"""
2021-12-15 00:09:07 +08:00
self.stop()
await self.join()
await self.close()
async def join(self):
"""
等待本客户端停止
"""
if not self.is_running:
2021-12-15 00:09:07 +08:00
logger.warning('room %s 已经停止不能join', self.room_id)
return
await self._network_future
2019-06-06 21:50:51 +08:00
2021-12-13 00:07:00 +08:00
async def close(self):
"""
2021-12-15 00:09:07 +08:00
释放本客户端的资源调用后本客户端将不可用
2021-12-13 00:07:00 +08:00
"""
if self.is_running:
2021-12-15 00:09:07 +08:00
logger.warning('room %s 在运行状态中调用了close', self.room_id)
# 如果session是自己创建的则关闭session
2021-12-13 00:07:00 +08:00
if self._own_session:
await self._session.close()
2019-06-06 21:50:51 +08:00
async def init_room(self):
"""
2021-12-15 21:12:09 +08:00
初始化连接房间需要的字段
:return: True代表没有降级如果需要降级后还可用重载这个函数返回True
"""
res = True
if not await self._init_room_id_and_owner():
res = False
# 失败了则降级
self._room_id = self._room_short_id = self._tmp_room_id
self._room_owner_uid = 0
if not await self._init_host_server():
res = False
# 失败了则降级
self._host_server_list = DEFAULT_DANMAKU_SERVER_LIST
self._host_server_token = None
return res
async def _init_room_id_and_owner(self):
2019-09-15 18:46:45 +08:00
try:
2020-07-19 22:17:32 +08:00
async with self._session.get(ROOM_INIT_URL, params={'room_id': self._tmp_room_id},
2019-09-15 18:46:45 +08:00
ssl=self._ssl) as res:
if res.status != 200:
2020-07-19 22:17:32 +08:00
logger.warning('room %d init_room失败%d %s', self._tmp_room_id,
2019-09-15 18:46:45 +08:00
res.status, res.reason)
return False
data = await res.json()
if data['code'] != 0:
2020-10-17 17:09:02 +08:00
logger.warning('room %d init_room失败%s', self._tmp_room_id, data['message'])
2019-09-15 18:46:45 +08:00
return False
if not self._parse_room_init(data['data']):
return False
2021-03-28 17:47:23 +08:00
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
2020-07-19 22:17:32 +08:00
logger.exception('room %d init_room失败', self._tmp_room_id)
2019-09-15 18:46:45 +08:00
return False
return True
def _parse_room_init(self, data):
room_info = data['room_info']
self._room_id = room_info['room_id']
self._room_short_id = room_info['short_id']
self._room_owner_uid = room_info['uid']
return True
2019-09-15 18:46:45 +08:00
async def _init_host_server(self):
2019-09-15 18:46:45 +08:00
try:
2020-07-19 22:17:32 +08:00
async with self._session.get(DANMAKU_SERVER_CONF_URL, params={'id': self._room_id, 'type': 0},
2019-09-15 18:46:45 +08:00
ssl=self._ssl) as res:
if res.status != 200:
2020-07-19 22:17:32 +08:00
logger.warning('room %d getConf失败%d %s', self._room_id,
2019-09-15 18:46:45 +08:00
res.status, res.reason)
return False
data = await res.json()
if data['code'] != 0:
2020-10-17 17:09:02 +08:00
logger.warning('room %d getConf失败%s', self._room_id, data['message'])
2019-09-15 18:46:45 +08:00
return False
if not self._parse_danmaku_server_conf(data['data']):
2019-09-15 18:46:45 +08:00
return False
2021-03-28 17:47:23 +08:00
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
2020-07-19 22:17:32 +08:00
logger.exception('room %d getConf失败', self._room_id)
2019-09-15 18:46:45 +08:00
return False
return True
2019-06-06 21:50:51 +08:00
def _parse_danmaku_server_conf(self, data):
self._host_server_list = data['host_list']
self._host_server_token = data['token']
if not self._host_server_list:
logger.warning('room %d getConf失败host_server_list为空', self._room_id)
return False
2019-09-15 18:46:45 +08:00
return True
2021-01-31 13:11:20 +08:00
@staticmethod
2021-12-15 21:12:09 +08:00
def _make_packet(data: dict, operation: int) -> bytes:
"""
创建一个要发送给服务器的包
:param data: 包体JSON数据
:param operation: 操作码见Operation
:return: 整个包的数据
"""
body = json.dumps(data).encode('utf-8')
2019-04-22 19:47:05 +08:00
header = HEADER_STRUCT.pack(
2021-12-15 21:12:09 +08:00
HEADER_STRUCT.size + len(body), # pack_len
HEADER_STRUCT.size, # raw_header_size
1, # ver
operation, # operation
1 # seq_id
)
return header + body
async def _network_coroutine_wrapper(self):
2021-12-15 21:12:09 +08:00
"""
负责处理网络协程的异常网络协程具体逻辑在_network_coroutine里
2021-12-15 21:12:09 +08:00
"""
try:
await self._network_coroutine()
except asyncio.CancelledError:
# 正常停止
pass
except Exception as e: # noqa
logger.exception('room %s 网络协程异常结束:', self.room_id)
finally:
logger.debug('room %s 网络协程结束', self.room_id)
self._network_future = None
2021-12-15 00:09:07 +08:00
async def _network_coroutine(self):
2021-12-15 21:12:09 +08:00
"""
网络协程负责连接服务器接收消息解包
"""
2019-09-15 18:46:45 +08:00
# 如果之前未初始化则初始化
if self._host_server_token is None:
if not await self.init_room():
raise InitError('初始化失败')
2019-06-07 20:02:37 +08:00
retry_count = 0
2018-05-14 01:02:53 +08:00
while True:
try:
# 连接
2019-09-15 18:46:45 +08:00
host_server = self._host_server_list[retry_count % len(self._host_server_list)]
async with self._session.ws_connect(
2021-12-15 21:12:09 +08:00
f"wss://{host_server['host']}:{host_server['wss_port']}/sub",
2021-01-31 13:47:32 +08:00
receive_timeout=self._heartbeat_interval + 5,
2019-09-15 18:46:45 +08:00
ssl=self._ssl
) as websocket:
self._websocket = websocket
2021-12-15 21:12:09 +08:00
await self._on_ws_connect()
2018-05-14 01:02:53 +08:00
# 处理消息
2021-12-12 21:54:07 +08:00
message: aiohttp.WSMessage
async for message in websocket:
retry_count = 0
2021-12-15 21:12:09 +08:00
await self._on_ws_message(message)
2018-05-13 23:49:32 +08:00
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
# 掉线重连
2019-06-07 20:02:37 +08:00
pass
2019-06-12 22:32:34 +08:00
except ssl_.SSLError:
logger.error('room %d 发生SSL错误无法重连', self.room_id)
raise
finally:
self._websocket = None
2021-12-15 21:12:09 +08:00
await self._on_ws_close()
2021-12-15 21:12:09 +08:00
# 准备重连
2019-06-07 20:02:37 +08:00
retry_count += 1
logger.warning('room %d 掉线重连中%d', self.room_id, retry_count)
await asyncio.sleep(1, loop=self._loop)
2019-06-07 20:02:37 +08:00
2021-12-15 21:12:09 +08:00
async def _on_ws_connect(self):
"""
websocket连接成功
"""
await self._send_auth()
self._heartbeat_timer_handle = self._loop.call_later(self._heartbeat_interval, self._on_send_heartbeat)
async def _on_ws_close(self):
"""
websocket连接断开
"""
if self._heartbeat_timer_handle is not None:
self._heartbeat_timer_handle.cancel()
self._heartbeat_timer_handle = None
async def _send_auth(self):
"""
发送认证包
"""
auth_params = {
'uid': self._uid,
'roomid': self._room_id,
'protover': 2,
'platform': 'web',
'clientver': '1.14.3',
'type': 2
}
if self._host_server_token is not None:
auth_params['key'] = self._host_server_token
await self._websocket.send_bytes(self._make_packet(auth_params, Operation.AUTH))
2021-01-31 13:11:20 +08:00
def _on_send_heartbeat(self):
2021-12-15 21:12:09 +08:00
"""
定时发送心跳包的回调
"""
2021-01-31 13:11:20 +08:00
coro = self._websocket.send_bytes(self._make_packet({}, Operation.HEARTBEAT))
asyncio.ensure_future(coro, loop=self._loop)
self._heartbeat_timer_handle = self._loop.call_later(self._heartbeat_interval, self._on_send_heartbeat)
2021-12-15 21:12:09 +08:00
async def _on_ws_message(self, message: aiohttp.WSMessage):
"""
收到websocket消息
:param message: websocket消息
"""
if message.type != aiohttp.WSMsgType.BINARY:
logger.warning('room %d 未知的websocket消息type=%s %s', self.room_id,
message.type, message.data)
return
try:
await self._parse_ws_message(message.data)
except asyncio.CancelledError:
# 正常停止,让外层处理
2021-12-15 21:12:09 +08:00
raise
except Exception: # noqa
logger.exception('room %d 处理websocket消息时发生错误', self.room_id)
async def _parse_ws_message(self, data: bytes):
"""
解析websocket消息
:param data: websocket消息数据
"""
offset = 0
2019-09-15 18:46:45 +08:00
while offset < len(data):
try:
2019-09-15 18:46:45 +08:00
header = HeaderTuple(*HEADER_STRUCT.unpack_from(data, offset))
except struct.error:
break
2019-04-22 19:47:05 +08:00
if header.operation == Operation.HEARTBEAT_REPLY:
2021-12-15 21:12:09 +08:00
# 心跳包,自己造个消息当成业务消息处理
2021-12-13 00:07:00 +08:00
popularity = int.from_bytes(
data[offset + HEADER_STRUCT.size: offset + HEADER_STRUCT.size + 4],
'big'
)
body = {
'cmd': '_HEARTBEAT',
'data': {
'popularity': popularity
}
}
await self._handle_command(body)
2019-04-22 19:47:05 +08:00
elif header.operation == Operation.SEND_MSG_REPLY:
2021-12-15 21:12:09 +08:00
# 业务消息
2019-09-15 18:46:45 +08:00
body = data[offset + HEADER_STRUCT.size: offset + header.pack_len]
2019-10-06 11:06:51 +08:00
if header.ver == WS_BODY_PROTOCOL_VERSION_DEFLATE:
2021-12-15 21:12:09 +08:00
# 压缩过的先解压,为了避免阻塞网络线程,放在其他线程执行
2021-01-31 13:11:20 +08:00
body = await self._loop.run_in_executor(None, zlib.decompress, body)
2021-12-15 21:12:09 +08:00
await self._parse_ws_message(body)
2019-09-15 18:46:45 +08:00
else:
2021-12-15 21:12:09 +08:00
# 没压缩过的
try:
body = json.loads(body.decode('utf-8'))
await self._handle_command(body)
2021-01-31 13:11:20 +08:00
except Exception:
logger.error('room %d body=%s', self.room_id, body)
raise
2019-04-22 19:47:05 +08:00
elif header.operation == Operation.AUTH_REPLY:
2021-12-15 21:12:09 +08:00
# 认证响应
2019-04-22 19:47:05 +08:00
await self._websocket.send_bytes(self._make_packet({}, Operation.HEARTBEAT))
else:
2021-12-15 21:12:09 +08:00
# 未知消息
2019-09-15 18:46:45 +08:00
body = data[offset + HEADER_STRUCT.size: offset + header.pack_len]
2019-06-06 21:50:51 +08:00
logger.warning('room %d 未知包类型operation=%d %s%s', self.room_id,
header.operation, header, body)
2019-04-22 19:47:05 +08:00
offset += header.pack_len
async def _handle_command(self, command: Union[list, dict]):
2021-12-15 21:12:09 +08:00
"""
解析并处理业务消息
2021-12-15 21:12:09 +08:00
:param command: 业务消息
"""
# 这里可能会多个消息一起发
if isinstance(command, list):
for one_command in command:
await self._handle_command(one_command)
return
results = await asyncio.gather(
*(handler.handle(self, command) for handler in self._handlers),
loop=self._loop,
return_exceptions=True
)
for res in results:
if isinstance(res, Exception):
logger.exception('room %d 处理消息时发生错误command=%s', self.room_id, command, exc_info=res)