Compare commits

..

57 Commits

Author SHA1 Message Date
John Smith
60efe1d55b 更新版本号v1.8.2 2023-09-23 10:07:48 +08:00
John Smith
b08906c150 补上房间语言配置 2023-09-21 23:52:54 +08:00
John Smith
c3b573baf6 缓解房间URL太长,被直播姬截断的问题 2023-09-21 23:37:58 +08:00
John Smith
dae5f782db 额外通过内容来判断是否是礼物弹幕 2023-09-20 22:50:50 +08:00
John Smith
39ab15e1ac 修复开放平台接口礼物显示的是单价而不是总价的问题 2023-09-18 20:08:08 +08:00
John Smith
49fe186dc4 更新版本号v1.8.1 2023-09-17 09:09:22 +08:00
John Smith
a11de39166 修复后端重连时报错 2023-09-16 23:01:45 +08:00
John Smith
dccb3fc890 降级身份码错误的日志 2023-09-16 12:23:01 +08:00
John Smith
ef7f0dc0ac 获取头像失败时尝试用用户名随机生成 2023-09-16 11:31:46 +08:00
John Smith
b1916608ee 前端当网页不可见时,延迟加载房间 2023-09-15 23:18:47 +08:00
John Smith
7e656ebe09 重连间隔时间可增长,防止一直重连失败时消耗太多资源 2023-09-15 22:47:17 +08:00
John Smith
be05184777 完善前端获取身份码的教程,做成弱智保姆软件 2023-09-14 23:01:11 +08:00
John Smith
63ef4ac971 缓存错误的身份码,避免旧版的请求传到开放平台 2023-09-13 00:45:44 +08:00
John Smith
05fc89dbbe 前端在身份码错误时跳到帮助页面,不再重试连接 2023-09-13 00:13:11 +08:00
John Smith
8bfceb4b32 临时屏蔽掉输错身份码的请求 2023-09-12 22:12:27 +08:00
John Smith
5dd56210d7 修复开发平台点赞消息报错 2023-09-12 21:54:15 +08:00
John Smith
b5c7554365 更新版本号v1.8.0 2023-09-10 10:40:19 +08:00
John Smith
c6272c908a 添加身份码相关的提示 2023-09-10 10:39:02 +08:00
John Smith
926ee879a6 修复打包后的报错 2023-09-10 09:48:12 +08:00
John Smith
a35d7a5bfc 移除自动缩小头像尺寸 2023-09-10 00:06:33 +08:00
John Smith
72c11d60da 移除从用户中心获取头像的接口 2023-09-10 00:03:29 +08:00
John Smith
a50461d4e6 添加重连次数太多的保险措施 2023-09-09 23:54:49 +08:00
John Smith
78828963ce 支持使用身份码时前端直连 2023-09-09 23:16:13 +08:00
John Smith
68f8ab3a92 前端支持使用身份码 2023-09-09 17:13:53 +08:00
John Smith
cad573312b 前端初始化房间时从后端接口获取文本表情 2023-09-08 23:54:47 +08:00
John Smith
3fb1c41845 前端直连回退到uid=0的版本,避免被限流 2023-09-08 23:49:35 +08:00
John Smith
6d07110927 添加获取文本表情的接口 2023-09-08 23:00:45 +08:00
John Smith
de0bac3119 添加开放平台接口的代理 2023-09-08 20:53:04 +08:00
John Smith
07569033fa 通过服务器转发时支持开放平台接口 2023-09-07 00:56:32 +08:00
John Smith
72841ab46e 优雅地停机 2023-09-06 21:34:04 +08:00
John Smith
fe141bc8fd 升级blivedm 2023-09-03 22:53:15 +08:00
John Smith
f5672c3e9c 修复前端样式生成器不显示文本消息 2023-08-31 23:45:03 +08:00
John Smith
b57bac3765 更新版本号v1.7.0 2023-08-31 19:16:00 +08:00
John Smith
7c2c48273c 纯表情消息不需要翻译 2023-08-28 19:36:33 +08:00
John Smith
5bdf2e41df 修复Python 3.11 asyncio.wait报错
close #120
2023-08-28 19:28:52 +08:00
John Smith
3d9095536d 支持官方的文本表情 2023-08-27 00:23:52 +08:00
John Smith
c3db0a41b3 添加host、port配置项 2023-08-26 17:01:19 +08:00
John Smith
60b9f81fe2 减少判断心跳超时的开销 2023-08-26 16:42:01 +08:00
John Smith
00c47a3e55 调整一些头像参数 2023-08-26 16:21:35 +08:00
John Smith
93e374ac12 添加其他头像接口 2023-08-24 23:07:17 +08:00
John Smith
dac531920e 添加通过勋章信息获取头像的接口
# Conflicts:
#	services/avatar.py

# Conflicts:
#	services/avatar.py
2023-08-24 22:29:34 +08:00
John Smith
c3566823c1 获取头像接口封装成类 2023-08-24 21:52:51 +08:00
John Smith
17b4985632 优化消息平滑算法 2023-08-06 13:50:27 +08:00
John Smith
4314bb61e9 升级Vue到2.7、前端VSCode插件改用Volar 2023-08-06 10:46:37 +08:00
John Smith
bea8711353 翻译区分优先级,高优先级的可重试 2023-08-05 21:27:40 +08:00
John Smith
8fa63f1d75 优化一些参数 2023-07-31 23:32:53 +08:00
John Smith
8e7d9b266b 使用LRU缓存、头像优先使用Protobuf协议中的、优化获取头像代码 2023-07-30 22:22:47 +08:00
John Smith
aea6f12bd2 添加启动时打开浏览器配置项 2023-07-30 15:29:40 +08:00
John Smith
ecb4a08e56 修复浏览器缓存导致前端没更新的问题 2023-07-29 23:05:04 +08:00
John Smith
6d5232d7d8 升级前端依赖 2023-07-29 18:34:02 +08:00
John Smith
63f541b87e 使用Python 3.8的asyncio特性 2023-07-29 12:48:57 +08:00
John Smith
e478959a1b 修改一个命名 2023-07-29 11:52:09 +08:00
John Smith
57ec8495e1 修复aiohttp的一些警告 2023-07-29 01:22:21 +08:00
John Smith
d5e8054e12 升级SQLAlchemy到2.0.19 2023-07-29 01:07:04 +08:00
John Smith
13b3e54f2b 升级blivedm 2023-07-28 00:45:24 +08:00
John Smith
f8520909f4 Merge pull request #116 from xfgryujk/dependabot/pip/aiohttp-3.8.5
Bump aiohttp from 3.7.4 to 3.8.5
2023-07-28 00:25:42 +08:00
dependabot[bot]
92cead6ff1 Bump aiohttp from 3.7.4 to 3.8.5
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.7.4 to 3.8.5.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/v3.8.5/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.7.4...v3.8.5)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-07-20 15:09:04 +00:00
46 changed files with 2693 additions and 1003 deletions

View File

@@ -2,13 +2,13 @@
# 构建前端
#
FROM node:16.14.0-bullseye AS builder
FROM node:18.17.0-bullseye AS builder
ARG BASE_PATH='/root/blivechat'
WORKDIR "${BASE_PATH}/frontend"
# 前端依赖
COPY frontend/package.json ./
RUN npm i --registry=https://registry.npmmirror.com
RUN npm i
# 编译前端
COPY frontend ./
@@ -24,8 +24,9 @@ ARG EXT_DATA_PATH='/mnt/data'
WORKDIR "${BASE_PATH}"
# 后端依赖
COPY blivedm/requirements.txt blivedm/
COPY requirements.txt ./
RUN pip3 install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
RUN pip3 install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple -r requirements.txt
# 数据目录
COPY . ./

View File

@@ -1,11 +1,15 @@
# blivechat
用于OBS的仿YouTube风格的bilibili直播评论栏
![OBS截图](./screenshots/obs.png)
![Chrome截图](./screenshots/chrome.png)
![样式生成器截图](./screenshots/stylegen.png)
![OBS截图](./screenshots/obs.png)
![Chrome截图](./screenshots/chrome.png)
![样式生成器截图](./screenshots/stylegen.png)
## 特性
* 兼容YouTube直播评论栏的样式
* 付费礼物模仿醒目留言显示
* 高亮舰队、房管、主播的用户名
@@ -17,70 +21,93 @@
* 支持配置自定义表情不需要开通B站官方表情
## 使用方法
以下几种方式任选一种即可
### 一、本地使用
1. 下载[发布版](https://github.com/xfgryujk/blivechat/releases)仅提供x64 Windows版
2. 双击`blivechat.exe`运行服务器或者用命令行可以指定host和端口号
```sh
blivechat.exe --host 127.0.0.1 --port 12450
```
3. 用浏览器打开[http://localhost:12450](http://localhost:12450)输入房间ID复制房间URL
或者也可以在配置文件里指定host和端口号
3. 用浏览器打开[http://localhost:12450](http://localhost:12450)输入主播在开始直播时获得的身份码复制房间URL
4. 用样式生成器生成样式复制CSS
5. 在OBS中添加浏览器源输入URL和自定义CSS
**注意事项:**
* 本地使用时不要关闭blivechat.exe那个黑框否则不能继续获取头像或弹幕
* 本地使用时不要关闭blivechat.exe那个黑框否则不能继续获取弹幕
* 样式生成器没有列出所有本地字体,但是可以手动输入本地字体
* 如果需要使用翻译功能,建议看[配置官方翻译接口傻瓜式教程](https://www.bilibili.com/read/cv14663633)
### 二、公共服务器
请优先在本地使用,使用公共服务器会有更大的延迟,而且服务器故障时可能发生直播事故
* [公共服务器](http://chat.bilisc.com/)
### 三、源代码版自建服务器或在Windows以外平台
0. 由于使用了git子模块clone时需要加上`--recursive`参数:
```sh
git clone --recursive https://github.com/xfgryujk/blivechat.git
```
如果已经clone拉子模块的方法
```sh
git submodule update --init --recursive
```
1. 编译前端需要安装Node.js
```sh
cd frontend
npm i
npm run build
```
2. 运行服务器需要Python3.8以上版本):
```sh
pip3 install -r requirements.txt
python3 main.py
```
或者可以指定host和端口号
```sh
python3 main.py --host 127.0.0.1 --port 12450
```
3. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
### 四、Docker自建服务器
1. ```sh
docker run --name blivechat -d -p 12450:12450 \
--mount source=blivechat-data,target=/mnt/data \
xfgryujk/blivechat:latest
```
2. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
## 自建服务器相关补充
### 服务器配置
服务器配置在`data/config.ini`,可以配置数据库和允许自动翻译等,编辑后要重启生效
**自建服务器时强烈建议不使用加载器**否则可能因为混合HTTP和HTTPS等原因加载不出来
### 参考nginx配置
`sudo vim /etc/nginx/sites-enabled/blivechat.conf`
```nginx
@@ -108,6 +135,8 @@ server {
ssl_certificate /PATH/TO/CERT.crt;
ssl_certificate_key /PATH/TO/CERT_KEY.key;
set $blivechat_path /PATH/TO/BLIVECHAT;
client_body_buffer_size 256k;
client_max_body_size 1.1m;
@@ -120,12 +149,20 @@ server {
# 静态文件
location / {
root /PATH/TO/BLIVECHAT/frontend/dist;
# 如果文件不存在,交给前端路由
try_files $uri $uri/ /index.html;
root $blivechat_path/frontend/dist;
try_files $uri $uri/ @index;
}
# 不存在的文件请求转发到index.html交给前端路由
location @index {
rewrite ^ /index.html last;
}
location = /index.html {
root $blivechat_path/frontend/dist;
# index.html不缓存防止更新后前端还是旧版
add_header Cache-Control no-cache;
}
location /emoticons {
alias /PATH/TO/BLIVECHAT/data/emoticons;
alias $blivechat_path/data/emoticons;
}
# 动态API
location /api {

View File

@@ -1,15 +1,18 @@
# -*- coding: utf-8 -*-
import json
from typing import *
import tornado.web
class ApiHandler(tornado.web.RequestHandler): # noqa
class ApiHandler(tornado.web.RequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.json_args = None
self.json_args: Optional[dict] = None
def prepare(self):
self.set_header('Cache-Control', 'no-cache')
if not self.request.headers.get('Content-Type', '').startswith('application/json'):
return
try:

View File

@@ -12,7 +12,7 @@ import aiohttp
import tornado.websocket
import api.base
import blivedm.blivedm.client as blivedm_client
import blivedm.blivedm.clients.web as dm_web_cli
import config
import services.avatar
import services.chat
@@ -31,6 +31,7 @@ class Command(enum.IntEnum):
ADD_SUPER_CHAT = 5
DEL_SUPER_CHAT = 6
UPDATE_TRANSLATION = 7
FATAL_ERROR = 8
class ContentType(enum.IntEnum):
@@ -38,6 +39,10 @@ class ContentType(enum.IntEnum):
EMOTICON = 1
class FatalErrorType(enum.IntEnum):
AUTH_CODE_ERROR = 1
def make_message_body(cmd, data):
return json.dumps(
{
@@ -96,6 +101,8 @@ def make_text_message_data(
content_type,
# 14: contentTypeParams
content_type_params if content_type_params is not None else [],
# 15: textEmoticons
[], # 已废弃,保留
]
@@ -115,7 +122,7 @@ def make_translation_message_data(msg_id, translation):
]
class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
class ChatHandler(tornado.websocket.WebSocketHandler):
HEARTBEAT_INTERVAL = 10
RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + 5
@@ -124,26 +131,26 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
self._heartbeat_timer_handle = None
self._receive_timeout_timer_handle = None
self.room_id = None
self.room_key: Optional[services.chat.RoomKey] = None
self.auto_translate = False
def open(self):
logger.info('client=%s connected', self.request.remote_ip)
self._heartbeat_timer_handle = asyncio.get_event_loop().call_later(
self._heartbeat_timer_handle = asyncio.get_running_loop().call_later(
self.HEARTBEAT_INTERVAL, self._on_send_heartbeat
)
self._refresh_receive_timeout_timer()
def _on_send_heartbeat(self):
self.send_cmd_data(Command.HEARTBEAT, {})
self._heartbeat_timer_handle = asyncio.get_event_loop().call_later(
self._heartbeat_timer_handle = asyncio.get_running_loop().call_later(
self.HEARTBEAT_INTERVAL, self._on_send_heartbeat
)
def _refresh_receive_timeout_timer(self):
if self._receive_timeout_timer_handle is not None:
self._receive_timeout_timer_handle.cancel()
self._receive_timeout_timer_handle = asyncio.get_event_loop().call_later(
self._receive_timeout_timer_handle = asyncio.get_running_loop().call_later(
self.RECEIVE_TIMEOUT, self._on_receive_timeout
)
@@ -153,9 +160,9 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
self.close()
def on_close(self):
logger.info('client=%s disconnected, room=%s', self.request.remote_ip, str(self.room_id))
logger.info('client=%s disconnected, room=%s', self.request.remote_ip, self.room_key)
if self.has_joined_room:
services.chat.client_room_manager.del_client(self.room_id, self)
services.chat.client_room_manager.del_client(self.room_key, self)
if self._heartbeat_timer_handle is not None:
self._heartbeat_timer_handle.cancel()
self._heartbeat_timer_handle = None
@@ -165,31 +172,16 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
def on_message(self, message):
try:
# 超时没有加入房间也断开
if self.has_joined_room:
self._refresh_receive_timeout_timer()
body = json.loads(message)
cmd = body['cmd']
cmd = int(body['cmd'])
if cmd == Command.HEARTBEAT:
pass
# 超时没有加入房间也断开
if self.has_joined_room:
self._refresh_receive_timeout_timer()
elif cmd == Command.JOIN_ROOM:
if self.has_joined_room:
return
self._refresh_receive_timeout_timer()
self.room_id = int(body['data']['roomId'])
logger.info('client=%s joining room %d', self.request.remote_ip, self.room_id)
try:
cfg = body['data']['config']
self.auto_translate = bool(cfg['autoTranslate'])
except KeyError:
pass
services.chat.client_room_manager.add_client(self.room_id, self)
asyncio.ensure_future(self._on_joined_room())
self._on_join_room_req(body)
else:
logger.warning('client=%s unknown cmd=%d, body=%s', self.request.remote_ip, cmd, body)
@@ -197,6 +189,41 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
except Exception: # noqa
logger.exception('client=%s on_message error, message=%s', self.request.remote_ip, message)
def _on_join_room_req(self, body: dict):
if self.has_joined_room:
return
data = body['data']
room_key_dict = data.get('roomKey', None)
if room_key_dict is not None:
room_key_type = services.chat.RoomKeyType(room_key_dict['type'])
room_key_value = room_key_dict['value']
if room_key_type == services.chat.RoomKeyType.ROOM_ID:
if not isinstance(room_key_value, int):
raise TypeError(f'Room key value type error, value={room_key_value}')
elif room_key_type == services.chat.RoomKeyType.AUTH_CODE:
if not isinstance(room_key_value, str):
raise TypeError(f'Room key value type error, value={room_key_value}')
else:
raise ValueError(f'Unknown RoomKeyType={room_key_type}')
else:
# 兼容旧版客户端 TODO 过几个版本可以移除
room_key_type = services.chat.RoomKeyType.ROOM_ID
room_key_value = int(data['roomId'])
self.room_key = services.chat.RoomKey(room_key_type, room_key_value)
logger.info('client=%s joining room %s', self.request.remote_ip, self.room_key)
try:
cfg = data['config']
self.auto_translate = bool(cfg['autoTranslate'])
except KeyError:
pass
services.chat.client_room_manager.add_client(self.room_key, self)
asyncio.create_task(self._on_joined_room())
self._refresh_receive_timeout_timer()
# 跨域测试用
def check_origin(self, origin):
if self.application.settings['debug']:
@@ -205,7 +232,7 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
@property
def has_joined_room(self):
return self.room_id is not None
return self.room_key is not None
def send_cmd_data(self, cmd, data):
self.send_body_no_raise(make_message_body(cmd, data))
@@ -223,7 +250,12 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
# 不允许自动翻译的提示
if self.auto_translate:
cfg = config.get_config()
if cfg.allow_translate_rooms and self.room_id not in cfg.allow_translate_rooms:
if (
cfg.allow_translate_rooms
# 身份码就不管了吧,反正配置正确的情况下不会看到这个提示
and self.room_key.type == services.chat.RoomKeyType.ROOM_ID
and self.room_key.value not in cfg.allow_translate_rooms
):
self.send_cmd_data(Command.ADD_TEXT, make_text_message_data(
author_name='blivechat',
author_type=2,
@@ -234,7 +266,7 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
# 测试用
async def _send_test_message(self):
base_data = {
'avatarUrl': await services.avatar.get_avatar_url(300474),
'avatarUrl': await services.avatar.get_avatar_url(300474, 'xfgryujk'),
'timestamp': int(time.time()),
'authorName': 'xfgryujk',
}
@@ -265,6 +297,9 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
'translation': ''
}
self.send_cmd_data(Command.ADD_TEXT, text_data)
text_data[4] = 'te[dog]st'
text_data[11] = uuid.uuid4().hex
self.send_cmd_data(Command.ADD_TEXT, text_data)
text_data[2] = '主播'
text_data[3] = 3
text_data[4] = "I can eat glass, it doesn't hurt me."
@@ -284,12 +319,13 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
self.send_cmd_data(Command.ADD_GIFT, gift_data)
class RoomInfoHandler(api.base.ApiHandler): # noqa
class RoomInfoHandler(api.base.ApiHandler):
async def get(self):
room_id = int(self.get_query_argument('roomId'))
logger.info('client=%s getting room info, room=%d', self.request.remote_ip, room_id)
room_id, owner_uid = await self._get_room_info(room_id)
host_server_list = await self._get_server_host_list(room_id)
# 连接其他host必须要key
host_server_list = dm_web_cli.DEFAULT_DANMAKU_SERVER_LIST
if owner_uid == 0:
# 缓存3分钟
self.set_header('Cache-Control', 'private, max-age=180')
@@ -306,7 +342,7 @@ class RoomInfoHandler(api.base.ApiHandler): # noqa
async def _get_room_info(room_id):
try:
async with utils.request.http_session.get(
blivedm_client.ROOM_INIT_URL,
dm_web_cli.ROOM_INIT_URL,
headers={
**utils.request.BILIBILI_COMMON_HEADERS,
'Origin': 'https://live.bilibili.com',
@@ -332,23 +368,33 @@ class RoomInfoHandler(api.base.ApiHandler): # noqa
room_info = data['data']['room_info']
return room_info['room_id'], room_info['uid']
@staticmethod
async def _get_server_host_list(_room_id):
# 连接其他host必须要key
return blivedm_client.DEFAULT_DANMAKU_SERVER_LIST
class AvatarHandler(api.base.ApiHandler): # noqa
class AvatarHandler(api.base.ApiHandler):
async def get(self):
uid = int(self.get_query_argument('uid'))
username = self.get_query_argument('username', '')
avatar_url = await services.avatar.get_avatar_url_or_none(uid)
if avatar_url is None:
avatar_url = services.avatar.DEFAULT_AVATAR_URL
avatar_url = services.avatar.get_default_avatar_url(uid, username)
# 缓存3分钟
self.set_header('Cache-Control', 'private, max-age=180')
else:
# 缓存1天
self.set_header('Cache-Control', 'private, max-age=86400')
self.write({
'avatarUrl': avatar_url
})
self.write({'avatarUrl': avatar_url})
class TextEmoticonMappingsHandler(api.base.ApiHandler):
async def get(self):
# 缓存1天
self.set_header('Cache-Control', 'private, max-age=86400')
cfg = config.get_config()
self.write({'textEmoticons': cfg.text_emoticons})
ROUTES = [
(r'/api/chat', ChatHandler),
(r'/api/room_info', RoomInfoHandler),
(r'/api/avatar_url', AvatarHandler),
(r'/api/text_emoticon_mappings', TextEmoticonMappingsHandler),
]

View File

@@ -16,19 +16,28 @@ EMOTICON_UPLOAD_PATH = os.path.join(config.DATA_PATH, 'emoticons')
EMOTICON_BASE_URL = '/emoticons'
class MainHandler(tornado.web.StaticFileHandler): # noqa
class MainHandler(tornado.web.StaticFileHandler):
"""为了使用Vue Router的history模式把不存在的文件请求转发到index.html"""
async def get(self, path, include_body=True):
if path == '':
await self._get_index(include_body)
return
try:
await super().get(path, include_body)
except tornado.web.HTTPError as e:
if e.status_code != 404:
raise
# 不存在的文件请求转发到index.html交给前端路由
await super().get('index.html', include_body)
await self._get_index(include_body)
async def _get_index(self, include_body=True):
# index.html不缓存防止更新后前端还是旧版
self.set_header('Cache-Control', 'no-cache')
await super().get('index.html', include_body)
class ServerInfoHandler(api.base.ApiHandler): # noqa
class ServerInfoHandler(api.base.ApiHandler):
async def get(self):
cfg = config.get_config()
self.write({
@@ -41,7 +50,7 @@ class ServerInfoHandler(api.base.ApiHandler): # noqa
})
class UploadEmoticonHandler(api.base.ApiHandler): # noqa
class UploadEmoticonHandler(api.base.ApiHandler):
async def post(self):
cfg = config.get_config()
if not cfg.enable_upload_file:
@@ -56,12 +65,10 @@ class UploadEmoticonHandler(api.base.ApiHandler): # noqa
if not file.content_type.lower().startswith('image/'):
raise tornado.web.HTTPError(415)
url = await asyncio.get_event_loop().run_in_executor(
url = await asyncio.get_running_loop().run_in_executor(
None, self._save_file, file.body, self.request.remote_ip
)
self.write({
'url': url
})
self.write({'url': url})
@staticmethod
def _save_file(body, client):
@@ -76,3 +83,14 @@ class UploadEmoticonHandler(api.base.ApiHandler): # noqa
os.replace(tmp_path, path)
return f'{EMOTICON_BASE_URL}/{filename}'
ROUTES = [
(r'/api/server_info', ServerInfoHandler),
(r'/api/emoticon', UploadEmoticonHandler),
]
# 通配的放在最后
LAST_ROUTES = [
(rf'{EMOTICON_BASE_URL}/(.*)', tornado.web.StaticFileHandler, {'path': EMOTICON_UPLOAD_PATH}),
(r'/(.*)', MainHandler, {'path': config.WEB_ROOT}),
]

245
api/open_live.py Normal file
View File

@@ -0,0 +1,245 @@
# -*- coding: utf-8 -*-
import asyncio
import datetime
import hashlib
import hmac
import json
import logging
import random
import re
from typing import *
import aiohttp
import cachetools
import tornado.web
import api.base
import config
import utils.request
logger = logging.getLogger(__name__)
START_GAME_OPEN_LIVE_URL = 'https://live-open.biliapi.com/v2/app/start'
END_GAME_OPEN_LIVE_URL = 'https://live-open.biliapi.com/v2/app/end'
GAME_HEARTBEAT_OPEN_LIVE_URL = 'https://live-open.biliapi.com/v2/app/heartbeat'
COMMON_SERVER_BASE_URL = 'https://chat.bilisc.com'
START_GAME_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/start_game'
END_GAME_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/end_game'
GAME_HEARTBEAT_COMMON_SERVER_URL = COMMON_SERVER_BASE_URL + '/api/internal/open_live/game_heartbeat'
_error_auth_code_cache = cachetools.LRUCache(256)
class TransportError(Exception):
"""网络错误或HTTP状态码错误"""
class BusinessError(Exception):
"""业务返回码错误"""
def __init__(self, data: dict):
super().__init__(f"code={data['code']}, message={data['message']}, request_id={data['request_id']}")
self.data = data
@property
def code(self) -> int:
return self.data['code']
async def request_open_live_or_common_server(open_live_url, common_server_url, body: dict) -> dict:
"""如果配置了开放平台,则直接请求,否则转发请求到公共服务器的内部接口"""
cfg = config.get_config()
if cfg.is_open_live_configured:
return await _request_open_live(open_live_url, body)
try:
req_ctx_mgr = utils.request.http_session.post(common_server_url, json=body)
return await _read_response(req_ctx_mgr)
except TransportError:
logger.exception('Request common server failed:')
raise
except BusinessError as e:
logger.warning('Request common server failed: %s', e)
raise
async def _request_open_live(url, body: dict) -> dict:
cfg = config.get_config()
assert cfg.is_open_live_configured
# 输错身份码的人太多了屏蔽掉明显错误的请求防止B站抱怨
if url == START_GAME_OPEN_LIVE_URL:
auth_code = body.get('code', '')
_validate_auth_code(auth_code)
else:
auth_code = ''
body_bytes = json.dumps(body).encode('utf-8')
headers = {
'x-bili-accesskeyid': cfg.open_live_access_key_id,
'x-bili-content-md5': hashlib.md5(body_bytes).hexdigest(),
'x-bili-signature-method': 'HMAC-SHA256',
'x-bili-signature-nonce': str(random.randint(0, 999999999)),
'x-bili-signature-version': '1.0',
'x-bili-timestamp': str(int(datetime.datetime.now().timestamp())),
}
str_to_sign = '\n'.join(
f'{key}:{value}'
for key, value in headers.items()
)
signature = hmac.new(
cfg.open_live_access_key_secret.encode('utf-8'), str_to_sign.encode('utf-8'), hashlib.sha256
).hexdigest()
headers['Authorization'] = signature
headers['Content-Type'] = 'application/json'
headers['Accept'] = 'application/json'
try:
req_ctx_mgr = utils.request.http_session.post(url, headers=headers, data=body_bytes)
return await _read_response(req_ctx_mgr)
except TransportError:
logger.exception('Request open live failed:')
raise
except BusinessError as e:
logger.warning('Request open live failed: %s', e)
if e.code == 7007:
_error_auth_code_cache[auth_code] = True
raise
async def _read_response(req_ctx_mgr: AsyncContextManager[aiohttp.ClientResponse]) -> dict:
try:
async with req_ctx_mgr as r:
r.raise_for_status()
data = await r.json()
code = data['code']
if code != 0:
raise BusinessError(data)
return data
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
raise TransportError(f'{type(e).__name__}: {e}')
def _validate_auth_code(auth_code):
if (
auth_code in _error_auth_code_cache
# 我也不知道是不是一定是这个格式,先这么处理
or not re.fullmatch(r'[0-9A-Z]{12,14}', auth_code)
):
raise BusinessError({
'code': 7007,
'message': 'oioioi你的身份码错误了别再重试了',
'request_id': '0',
'data': None
})
class _OpenLiveHandlerBase(api.base.ApiHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.res: Optional[dict] = None
def prepare(self):
super().prepare()
if not isinstance(self.json_args, dict):
raise tornado.web.MissingArgumentError('body')
if 'app_id' in self.json_args:
cfg = config.get_config()
self.json_args['app_id'] = cfg.open_live_app_id
logger.info('client=%s requesting open live, cls=%s', self.request.remote_ip, type(self).__name__)
class _PublicHandlerBase(_OpenLiveHandlerBase):
"""外部接口,如果配置了开放平台,则直接请求,否则转发请求到公共服务器的内部接口"""
_OPEN_LIVE_URL: str
_COMMON_SERVER_URL: str
async def post(self):
try:
self.res = await request_open_live_or_common_server(
self._OPEN_LIVE_URL, self._COMMON_SERVER_URL, self.json_args
)
except TransportError:
raise tornado.web.HTTPError(500)
except BusinessError as e:
self.res = e.data
self.write(self.res)
class _PrivateHandlerBase(_OpenLiveHandlerBase):
"""内部接口,如果配置了开放平台,则直接请求,否则响应错误"""
_OPEN_LIVE_URL: str
async def post(self):
cfg = config.get_config()
if not cfg.is_open_live_configured:
raise tornado.web.HTTPError(501)
try:
self.res = await _request_open_live(self._OPEN_LIVE_URL, self.json_args)
except TransportError:
raise tornado.web.HTTPError(500)
except BusinessError as e:
self.res = e.data
self.write(self.res)
class _StartGameMixin(_OpenLiveHandlerBase):
_OPEN_LIVE_URL = START_GAME_OPEN_LIVE_URL
_COMMON_SERVER_URL = START_GAME_COMMON_SERVER_URL
async def post(self):
await super().post() # noqa
if self.res is None:
return
try:
room_id = self.res['data']['anchor_info']['room_id']
except (TypeError, KeyError):
room_id = None
code = self.res['code']
logger.info('room_id=%s start game res: %s %s', room_id, code, self.res['message'])
if code == 7007:
# 身份码错误
# 让我看看是哪个混蛋把房间ID、UID当做身份码
logger.info('Auth code error! auth_code=%s', self.json_args.get('code', None))
class StartGamePublicHandler(_StartGameMixin, _PublicHandlerBase):
pass
class StartGamePrivateHandler(_StartGameMixin, _PrivateHandlerBase):
pass
class EndGamePublicHandler(_PublicHandlerBase):
_OPEN_LIVE_URL = END_GAME_OPEN_LIVE_URL
_COMMON_SERVER_URL = END_GAME_COMMON_SERVER_URL
class EndGamePrivateHandler(_PrivateHandlerBase):
_OPEN_LIVE_URL = END_GAME_OPEN_LIVE_URL
class GameHeartbeatPublicHandler(_PublicHandlerBase):
_OPEN_LIVE_URL = GAME_HEARTBEAT_OPEN_LIVE_URL
_COMMON_SERVER_URL = GAME_HEARTBEAT_COMMON_SERVER_URL
class GameHeartbeatPrivateHandler(_PrivateHandlerBase):
_OPEN_LIVE_URL = GAME_HEARTBEAT_OPEN_LIVE_URL
ROUTES = [
(r'/api/open_live/start_game', StartGamePublicHandler),
(r'/api/internal/open_live/start_game', StartGamePrivateHandler),
(r'/api/open_live/end_game', EndGamePublicHandler),
(r'/api/internal/open_live/end_game', EndGamePrivateHandler),
(r'/api/open_live/game_heartbeat', GameHeartbeatPublicHandler),
(r'/api/internal/open_live/game_heartbeat', GameHeartbeatPrivateHandler),
]

Submodule blivedm updated: 9d92fcdc91...4da27950c8

View File

@@ -49,20 +49,35 @@ def get_config():
class AppConfig:
def __init__(self):
self.host = '127.0.0.1'
self.port = 12450
self.database_url = 'sqlite:///data/database.db'
self.tornado_xheaders = False
self.loader_url = ''
self.open_browser_at_startup = True
self.enable_upload_file = True
self.fetch_avatar_interval = 3.5
self.fetch_avatar_max_queue_size = 2
self.avatar_cache_size = 50000
self.fetch_avatar_max_queue_size = 4
self.avatar_cache_size = 10000
self.open_live_access_key_id = ''
self.open_live_access_key_secret = ''
self.open_live_app_id = 0
self.enable_translate = True
self.allow_translate_rooms = set()
self.translate_max_queue_size = 10
self.translation_cache_size = 50000
self.translator_configs = []
self.text_emoticons = []
@property
def is_open_live_configured(self):
return (
self.open_live_access_key_id != '' and self.open_live_access_key_secret != '' and self.open_live_app_id != 0
)
def load(self, path):
try:
config = configparser.ConfigParser()
@@ -70,6 +85,7 @@ class AppConfig:
self._load_app_config(config)
self._load_translator_configs(config)
self._load_text_emoticons(config)
except Exception: # noqa
logger.exception('Failed to load config:')
return False
@@ -77,18 +93,28 @@ class AppConfig:
def _load_app_config(self, config: configparser.ConfigParser):
app_section = config['app']
self.host = app_section.get('host', self.host)
self.port = app_section.getint('port', self.port)
self.database_url = app_section.get('database_url', self.database_url)
self.tornado_xheaders = app_section.getboolean('tornado_xheaders', fallback=self.tornado_xheaders)
self.tornado_xheaders = app_section.getboolean('tornado_xheaders', self.tornado_xheaders)
self.loader_url = app_section.get('loader_url', self.loader_url)
self.enable_upload_file = app_section.getboolean('enable_upload_file', fallback=self.enable_upload_file)
self.open_browser_at_startup = app_section.getboolean('open_browser_at_startup', self.open_browser_at_startup)
self.enable_upload_file = app_section.getboolean('enable_upload_file', self.enable_upload_file)
self.fetch_avatar_interval = app_section.getfloat('fetch_avatar_interval', fallback=self.fetch_avatar_interval)
self.fetch_avatar_max_queue_size = app_section.getint('fetch_avatar_max_queue_size',
fallback=self.fetch_avatar_max_queue_size)
self.avatar_cache_size = app_section.getint('avatar_cache_size', fallback=self.avatar_cache_size)
self.fetch_avatar_max_queue_size = app_section.getint(
'fetch_avatar_max_queue_size', self.fetch_avatar_max_queue_size
)
self.avatar_cache_size = app_section.getint('avatar_cache_size', self.avatar_cache_size)
self.enable_translate = app_section.getboolean('enable_translate', fallback=self.enable_translate)
self.open_live_access_key_id = app_section.get('open_live_access_key_id', self.open_live_access_key_id)
self.open_live_access_key_secret = app_section.get(
'open_live_access_key_secret', self.open_live_access_key_secret
)
self.open_live_app_id = app_section.getint('open_live_app_id', self.open_live_app_id)
self.enable_translate = app_section.getboolean('enable_translate', self.enable_translate)
self.allow_translate_rooms = _str_to_list(app_section.get('allow_translate_rooms', ''), int, set)
self.translate_max_queue_size = app_section.getint('translate_max_queue_size', self.translate_max_queue_size)
self.translation_cache_size = app_section.getint('translation_cache_size', self.translation_cache_size)
def _load_translator_configs(self, config: configparser.ConfigParser):
@@ -103,7 +129,6 @@ class AppConfig:
translator_config = {
'type': type_,
'query_interval': section.getfloat('query_interval'),
'max_queue_size': section.getint('max_queue_size')
}
if type_ == 'TencentTranslateFree':
translator_config['source_language'] = section['source_language']
@@ -130,6 +155,14 @@ class AppConfig:
translator_configs.append(translator_config)
self.translator_configs = translator_configs
def _load_text_emoticons(self, config: configparser.ConfigParser):
mappings_section = config['text_emoticon_mappings']
text_emoticons = []
for value in mappings_section.values():
keyword, _, url = value.partition(',')
text_emoticons.append({'keyword': keyword, 'url': url})
self.text_emoticons = text_emoticons
def _str_to_list(value, item_type: Type = str, container_type: Type = list):
value = value.strip()

View File

@@ -2,8 +2,13 @@
# If you want to modify the configuration, copy this file and rename it to "config.ini" and edit
[app]
# 数据库配置见https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls
# See https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls
# 服务器监听的地址
# The address the server listens on
host = 127.0.0.1
port = 12450
# 数据库配置,见 https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls
# See https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls
database_url = sqlite:///data/database.db
# 如果使用了nginx之类的反向代理服务器设置为true
@@ -15,22 +20,22 @@ tornado_xheaders = false
# Use a loader so that you can run OBS before blivechat. If empty, no loader is used
loader_url = https://xfgryujk.sinacloud.net/blivechat/loader.html
# 启动时打开浏览器
# Open browser at startup
open_browser_at_startup = true
# 允许上传自定义表情文件
# Enable uploading custom emote file
enable_upload_file = true
# 获取头像间隔时间。如果小于3秒有很大概率被服务器拉黑
# Interval between fetching avatars (seconds). At least 3 seconds is recommended
fetch_avatar_interval = 3.5
# 获取头像最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间
# 获取头像最大队列长度
# Maximum queue length for fetching avatar
fetch_avatar_max_queue_size = 2
fetch_avatar_max_queue_size = 4
# 头像缓存数量
# Number of avatar caches
avatar_cache_size = 50000
# 内存中头像缓存数量
# Number of avatar caches in memory
avatar_cache_size = 10000
# 允许自动翻译到日语
@@ -42,6 +47,10 @@ enable_translate = true
# Example: allow_translate_rooms = 4895312,22347054,21693691
allow_translate_rooms =
# 翻译最大队列长度
# Maximum queue length for translating
translate_max_queue_size = 10
# 翻译缓存数量
# Number of translation caches
translation_cache_size = 50000
@@ -52,6 +61,13 @@ translation_cache_size = 50000
# **The following is for translation team. Leave it default if you don't know its meaning**
# -------------------------------------------------------------------------------------------------
# 在B站直播开放平台申请的开发者密钥如果不填会把请求转发到作者的服务器
open_live_access_key_id =
open_live_access_key_secret =
# 在B站直播开放平台创建的项目ID如果不填会把请求转发到作者的服务器
open_live_app_id = 0
# 翻译器配置,索引到下面的配置节。可以以逗号分隔配置多个翻译器,翻译时会自动负载均衡
# 配置多个翻译器可以增加额度、增加QPS、容灾
# 不同配置可以使用同一个类型,但要使用不同的账号,否则还是会遇到额度、调用频率限制
@@ -62,10 +78,8 @@ translator_configs = tencent_translate_free
# 类型:腾讯翻译白嫖版。使用了网页版的接口,**将来可能失效**
type = TencentTranslateFree
# 请求间隔时间(秒),等于 1 / QPS。目前没有遇到此接口有调用频率限制10QPS应该够用了
query_interval = 0.1
# 最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间
max_queue_size = 100
# 请求间隔时间(秒),等于 1 / QPS
query_interval = 1
# 自动auto中文zh日语jp英语en韩语kr
# 完整语言列表见文档https://cloud.tencent.com/document/product/551/15619
@@ -89,8 +103,6 @@ type = TencentTranslate
# 请求间隔时间(秒),等于 1 / QPS。理论上最高QPS为5实际测试是3
query_interval = 0.333
# 最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间
max_queue_size = 30
# 自动auto中文zh日语ja英语en韩语ko
# 完整语言列表见文档https://cloud.tencent.com/document/product/551/15619
@@ -112,17 +124,15 @@ region = ap-shanghai
[baidu_translate]
# 文档https://fanyi-api.baidu.com/
# 定价https://fanyi-api.baidu.com/product/112
# * 标准版完全免费不限使用字符量QPS=1
# * 高级版每月前200万字符免费超出仅收取超出部分费用QPS=1049元/百万字符
# * 尊享版每月前200万字符免费超出后仅收取超出部分费用QPS=10049元/百万字符
# * 标准版每月前5万字符免费超出仅收取超出部分费用QPS=1按49元/百万字符计费
# * 高级版每月前100万字符免费超出仅收取超出部分费用QPS=1049元/百万字符计费
# * 尊享版每月前200万字符免费超出后仅收取超出部分费用QPS=10049元/百万字符计费
# 类型:百度翻译
type = BaiduTranslate
# 请求间隔时间(秒),等于 1 / QPS
query_interval = 1.5
# 最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间
max_queue_size = 9
# 自动auto中文zh日语jp英语en韩语kor
# 完整语言列表见文档https://fanyi-api.baidu.com/doc/21
@@ -134,3 +144,89 @@ target_language = jp
# 百度翻译开放平台应用ID和密钥
app_id =
secret =
# 傻逼B站获取表情都要登录开放平台也不发文本表情的URL我服了
[text_emoticon_mappings]
1 = [dog],http://i0.hdslb.com/bfs/live/4428c84e694fbf4e0ef6c06e958d9352c3582740.png
2 = [花],http://i0.hdslb.com/bfs/live/7dd2ef03e13998575e4d8a803c6e12909f94e72b.png
3 = [妙],http://i0.hdslb.com/bfs/live/08f735d950a0fba267dda140673c9ab2edf6410d.png
4 = [哇],http://i0.hdslb.com/bfs/live/650c3e22c06edcbca9756365754d38952fc019c3.png
5 = [爱],http://i0.hdslb.com/bfs/live/1daaa5d284dafaa16c51409447da851ff1ec557f.png
6 = [手机],http://i0.hdslb.com/bfs/live/b159f90431148a973824f596288e7ad6a8db014b.png
7 = [撇嘴],http://i0.hdslb.com/bfs/live/4255ce6ed5d15b60311728a803d03dd9a24366b2.png
8 = [委屈],http://i0.hdslb.com/bfs/live/69312e99a00d1db2de34ef2db9220c5686643a3f.png
9 = [抓狂],http://i0.hdslb.com/bfs/live/a7feb260bb5b15f97d7119b444fc698e82516b9f.png
10 = [比心],http://i0.hdslb.com/bfs/live/4e029593562283f00d39b99e0557878c4199c71d.png
11 = [赞],http://i0.hdslb.com/bfs/live/2dd666d3651bafe8683acf770b7f4163a5f49809.png
12 = [滑稽],http://i0.hdslb.com/bfs/live/8624fd172037573c8600b2597e3731ef0e5ea983.png
13 = [吃瓜],http://i0.hdslb.com/bfs/live/ffb53c252b085d042173379ac724694ce3196194.png
14 = [笑哭],http://i0.hdslb.com/bfs/live/c5436c6806c32b28d471bb23d42f0f8f164a187a.png
15 = [捂脸],http://i0.hdslb.com/bfs/live/e6073c6849f735ae6cb7af3a20ff7dcec962b4c5.png
16 = [喝彩],http://i0.hdslb.com/bfs/live/b51824125d09923a4ca064f0c0b49fc97d3fab79.png
17 = [偷笑],http://i0.hdslb.com/bfs/live/e2ba16f947a23179cdc00420b71cc1d627d8ae25.png
18 = [大笑],http://i0.hdslb.com/bfs/live/e2589d086df0db8a7b5ca2b1273c02d31d4433d4.png
19 = [惊喜],http://i0.hdslb.com/bfs/live/9c75761c5b6e1ff59b29577deb8e6ad996b86bd7.png
20 = [傲娇],http://i0.hdslb.com/bfs/live/b5b44f099059a1bafb2c2722cfe9a6f62c1dc531.png
21 = [疼],http://i0.hdslb.com/bfs/live/492b10d03545b7863919033db7d1ae3ef342df2f.png
22 = [吓],http://i0.hdslb.com/bfs/live/c6bed64ffb78c97c93a83fbd22f6fdf951400f31.png
23 = [阴险],http://i0.hdslb.com/bfs/live/a4df45c035b0ca0c58f162b5fb5058cf273d0d09.png
24 = [惊讶],http://i0.hdslb.com/bfs/live/bc26f29f62340091737c82109b8b91f32e6675ad.png
25 = [生病],http://i0.hdslb.com/bfs/live/84c92239591e5ece0f986c75a39050a5c61c803c.png
26 = [嘘],http://i0.hdslb.com/bfs/live/b6226219384befa5da1d437cb2ff4ba06c303844.png
27 = [奸笑],http://i0.hdslb.com/bfs/live/5935e6a4103d024955f749d428311f39e120a58a.png
28 = [囧],http://i0.hdslb.com/bfs/live/204413d3cf330e122230dcc99d29056f2a60e6f2.png
29 = [捂脸2],http://i0.hdslb.com/bfs/live/a2ad0cc7e390a303f6d243821479452d31902a5f.png
30 = [出窍],http://i0.hdslb.com/bfs/live/bb8e95fa54512ffea07023ea4f2abee4a163e7a0.png
31 = [吐了啊],http://i0.hdslb.com/bfs/live/2b6b4cc33be42c3257dc1f6ef3a39d666b6b4b1a.png
32 = [鼻子],http://i0.hdslb.com/bfs/live/f4ed20a70d0cb85a22c0c59c628aedfe30566b37.png
33 = [调皮],http://i0.hdslb.com/bfs/live/84fe12ecde5d3875e1090d83ac9027cb7d7fba9f.png
34 = [酸],http://i0.hdslb.com/bfs/live/98fd92c6115b0d305f544b209c78ec322e4bb4ff.png
35 = [冷],http://i0.hdslb.com/bfs/live/b804118a1bdb8f3bec67d9b108d5ade6e3aa93a9.png
36 = [OK],http://i0.hdslb.com/bfs/live/86268b09e35fbe4215815a28ef3cf25ec71c124f.png
37 = [微笑],http://i0.hdslb.com/bfs/live/f605dd8229fa0115e57d2f16cb019da28545452b.png
38 = [藏狐],http://i0.hdslb.com/bfs/live/05ef7849e7313e9c32887df922613a7c1ad27f12.png
39 = [龇牙],http://i0.hdslb.com/bfs/live/8b99266ea7b9e86cf9d25c3d1151d80c5ba5c9a1.png
40 = [防护],http://i0.hdslb.com/bfs/live/17435e60dcc28ce306762103a2a646046ff10b0a.png
41 = [笑],http://i0.hdslb.com/bfs/live/a91a27f83c38b5576f4cd08d4e11a2880de78918.png
42 = [一般],http://i0.hdslb.com/bfs/live/8d436de0c3701d87e4ca9c1be01c01b199ac198e.png
43 = [嫌弃],http://i0.hdslb.com/bfs/live/c409425ba1ad2c6534f0df7de350ba83a9c949e5.png
44 = [无语],http://i0.hdslb.com/bfs/live/4781a77be9c8f0d4658274eb4e3012c47a159f23.png
45 = [哈欠],http://i0.hdslb.com/bfs/live/6e496946725cd66e7ff1b53021bf1cc0fc240288.png
46 = [可怜],http://i0.hdslb.com/bfs/live/8e88e6a137463703e96d4f27629f878efa323456.png
47 = [歪嘴笑],http://i0.hdslb.com/bfs/live/bea1f0497888f3e9056d3ce14ba452885a485c02.png
48 = [亲亲],http://i0.hdslb.com/bfs/live/10662d9c0d6ddb3203ecf50e77788b959d4d1928.png
49 = [问号],http://i0.hdslb.com/bfs/live/a0c456b6d9e3187399327828a9783901323bfdb5.png
50 = [波吉],http://i0.hdslb.com/bfs/live/57dee478868ed9f1ce3cf25a36bc50bde489c404.png
51 = [OH],http://i0.hdslb.com/bfs/live/0d5123cddf389302df6f605087189fd10919dc3c.png
52 = [再见],http://i0.hdslb.com/bfs/live/f408e2af700adcc2baeca15510ef620bed8d4c43.png
53 = [白眼],http://i0.hdslb.com/bfs/live/7fa907ae85fa6327a0466e123aee1ac32d7c85f7.png
54 = [鼓掌],http://i0.hdslb.com/bfs/live/d581d0bc30c8f9712b46ec02303579840c72c42d.png
55 = [大哭],http://i0.hdslb.com/bfs/live/816402551e6ce30d08b37a917f76dea8851fe529.png
56 = [呆],http://i0.hdslb.com/bfs/live/179c7e2d232cd74f30b672e12fc728f8f62be9ec.png
57 = [流汗],http://i0.hdslb.com/bfs/live/b00e2e02904096377061ec5f93bf0dd3321f1964.png
58 = [生气],http://i0.hdslb.com/bfs/live/2c69dad2e5c0f72f01b92746bc9d148aee1993b2.png
59 = [加油],http://i0.hdslb.com/bfs/live/fbc3c8bc4152a65bbf4a9fd5a5d27710fbff2119.png
60 = [害羞],http://i0.hdslb.com/bfs/live/d8ce9b05c0e40cec61a15ba1979c8517edd270bf.png
61 = [虎年],http://i0.hdslb.com/bfs/live/a51af0d7d9e60ce24f139c468a3853f9ba9bb184.png
62 = [doge2],http://i0.hdslb.com/bfs/live/f547cc853cf43e70f1e39095d9b3b5ac1bf70a8d.png
63 = [金钱豹],http://i0.hdslb.com/bfs/live/b6e8131897a9a718ee280f2510bfa92f1d84429b.png
64 = [瓜子],http://i0.hdslb.com/bfs/live/fd35718ac5a278fd05fe5287ebd41de40a59259d.png
65 = [墨镜],http://i0.hdslb.com/bfs/live/5e01c237642c8b662a69e21b8e0fbe6e7dbc2aa1.png
66 = [难过],http://i0.hdslb.com/bfs/live/5776481e380648c0fb3d4ad6173475f69f1ce149.png
67 = [抱抱],http://i0.hdslb.com/bfs/live/abddb0b621b389fc8c2322b1cfcf122d8936ba91.png
68 = [跪了],http://i0.hdslb.com/bfs/live/4f2155b108047d60c1fa9dccdc4d7abba18379a0.png
69 = [摊手],http://i0.hdslb.com/bfs/live/1e0a2baf088a34d56e2cc226b2de36a5f8d6c926.png
70 = [热],http://i0.hdslb.com/bfs/live/6df760280b17a6cbac8c1874d357298f982ba4cf.png
71 = [三星堆],http://i0.hdslb.com/bfs/live/0a1ab3f0f2f2e29de35c702ac1ecfec7f90e325d.png
72 = [鼠],http://i0.hdslb.com/bfs/live/98f842994035505c728e32e32045d649e371ecd6.png
73 = [汤圆],http://i0.hdslb.com/bfs/live/23ae12d3a71b9d7a22c8773343969fcbb94b20d0.png
74 = [泼水],http://i0.hdslb.com/bfs/live/29533893115c4609a4af336f49060ea13173ca78.png
75 = [鬼魂],http://i0.hdslb.com/bfs/live/5d86d55ba9a2f99856b523d8311cf75cfdcccdbc.png
76 = [不行],http://i0.hdslb.com/bfs/live/607f74ccf5eec7d2b17d91b9bb36be61a5dd196b.png
77 = [响指],http://i0.hdslb.com/bfs/live/3b2fedf09b0ac79679b5a47f5eb3e8a38e702387.png
78 = [牛],http://i0.hdslb.com/bfs/live/5e61223561203c50340b4c9b41ba7e4b05e48ae2.png
79 = [保佑],http://i0.hdslb.com/bfs/live/241b13adb4933e38b7ea6f5204e0648725e76fbf.png
80 = [抱拳],http://i0.hdslb.com/bfs/live/3f170894dd08827ee293afcb5a3d2b60aecdb5b1.png
81 = [给力],http://i0.hdslb.com/bfs/live/d1ba5f4c54332a21ed2ca0dcecaedd2add587839.png
82 = [耶],http://i0.hdslb.com/bfs/live/eb2d84ba623e2335a48f73fb5bef87bcf53c1239.png

View File

@@ -5,7 +5,7 @@ module.exports = {
"node": true
},
"parserOptions": {
"parser": "babel-eslint"
"parser": "@babel/eslint-parser"
},
"extends": [
"plugin:vue/essential",
@@ -75,5 +75,7 @@ module.exports = {
"no-shadow": "warn", // 变量名和外部作用域重复
"no-console": "off", // 线上尽量不要用console输出看不到的
"vue/multi-word-component-names": "off", // Vue组件名允许用1个单词
}
}

View File

@@ -7,6 +7,9 @@
"@/*": ["src/*"]
}
},
"vueCompilerOptions": {
"target": 2.7,
},
"include": [
"./src/**/*.js",
"./src/**/*.vue"

View File

@@ -1,6 +1,6 @@
{
"name": "blivechat",
"version": "1.6.2",
"version": "1.8.2",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
@@ -8,32 +8,29 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^0.21.1",
"core-js": "^3.6.5",
"axios": "^1.4.0",
"core-js": "^3.8.3",
"downloadjs": "^1.4.7",
"element-ui": "^2.9.1",
"lodash": "^4.17.19",
"vue": "^2.6.10",
"vue-i18n": "^8.11.2",
"vue-router": "^3.0.6"
"element-ui": "^2.15.13",
"lodash": "^4.17.21",
"pako": "^2.1.0",
"vue": "^2.7.14",
"vue-i18n": "^8.28.2",
"vue-router": "^3.6.5"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^4.5.12",
"@vue/cli-plugin-eslint": "^4.5.12",
"@vue/cli-service": "~4.5.12",
"babel-eslint": "^10.1.0",
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"babel-plugin-component": "^1.1.1",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"vue-template-compiler": "^2.5.21"
},
"postcss": {
"plugins": {
"autoprefixer": {}
}
"eslint": "^7.32.0",
"eslint-plugin-vue": "^9.16.1"
},
"browserslist": [
"> 1%",
"last 2 versions"
"last 2 versions",
"not dead"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 264 KiB

View File

@@ -0,0 +1,263 @@
import axios from 'axios'
import * as chat from '.'
import * as base from './ChatClientOfficialBase'
import ChatClientOfficialBase from './ChatClientOfficialBase'
const GAME_HEARTBEAT_INTERVAL = 20 * 1000
export default class ChatClientDirectOpenLive extends ChatClientOfficialBase {
constructor(roomOwnerAuthCode) {
super()
this.CMD_CALLBACK_MAP = CMD_CALLBACK_MAP
this.roomOwnerAuthCode = roomOwnerAuthCode
// 调用initRoom后初始化
this.roomOwnerUid = null
this.hostServerUrlList = []
this.authBody = null
this.gameId = null
this.gameHeartbeatTimerId = null
}
stop() {
super.stop()
if (this.gameHeartbeatTimerId) {
window.clearInterval(this.gameHeartbeatTimerId)
this.gameHeartbeatTimerId = null
}
this.endGame()
}
async initRoom() {
if (!await this.startGame()) {
return false
}
if (this.gameId && this.gameHeartbeatTimerId === null) {
this.gameHeartbeatTimerId = window.setInterval(this.sendGameHeartbeat.bind(this), GAME_HEARTBEAT_INTERVAL)
}
return true
}
async startGame() {
let res
try {
res = (await axios.post('/api/open_live/start_game', {
code: this.roomOwnerAuthCode,
app_id: 0
})).data
if (res.code !== 0) {
let msg = `code=${res.code}, message=${res.message}, request_id=${res.request_id}`
if (res.code === 7007) {
// 身份码错误
throw new chat.ChatClientFatalError(chat.FATAL_ERROR_TYPE_AUTH_CODE_ERROR, msg)
}
throw Error(msg)
}
} catch (e) {
console.error('startGame failed:', e)
if (e instanceof chat.ChatClientFatalError) {
throw e
}
return false
}
let data = res.data
this.gameId = data.game_info.game_id
let websocketInfo = data.websocket_info
this.authBody = websocketInfo.auth_body
this.hostServerUrlList = websocketInfo.wss_link
let anchorInfo = data.anchor_info
// this.roomId = anchorInfo.room_id
this.roomOwnerUid = anchorInfo.uid
return true
}
async endGame() {
if (!this.gameId) {
return true
}
try {
let res = (await axios.post('/api/open_live/end_game', {
app_id: 0,
game_id: this.gameId
})).data
if (res.code !== 0) {
if (res.code === 7000 || res.code === 7003) {
// 项目已经关闭了也算成功
return true
}
throw Error(`code=${res.code}, message=${res.message}, request_id=${res.request_id}`)
}
} catch (e) {
console.error('endGame failed:', e)
return false
}
return true
}
async sendGameHeartbeat() {
if (!this.gameId) {
return false
}
// 保存一下防止await之后gameId改变
let gameId = this.gameId
try {
let res = (await axios.post('/api/open_live/game_heartbeat', {
game_id: this.gameId
})).data
if (res.code !== 0) {
console.error(`sendGameHeartbeat failed: code=${res.code}, message=${res.message}, request_id=${res.request_id}`)
if (res.code === 7003 && this.gameId === gameId) {
// 项目异常关闭,可能是心跳超时,需要重新开启项目
this.needInitRoom = true
this.discardWebsocket()
}
return false
}
} catch (e) {
console.error('sendGameHeartbeat failed:', e)
return false
}
return true
}
async onBeforeWsConnect() {
// 重连次数太多则重新init_room保险
let reinitPeriod = Math.max(3, (this.hostServerUrlList || []).length)
if (this.retryCount > 0 && this.retryCount % reinitPeriod === 0) {
this.needInitRoom = true
}
return super.onBeforeWsConnect()
}
getWsUrl() {
return this.hostServerUrlList[this.retryCount % this.hostServerUrlList.length]
}
sendAuth() {
this.websocket.send(this.makePacket(this.authBody, base.OP_AUTH))
}
async dmCallback(command) {
if (!this.onAddText) {
return
}
let data = command.data
let authorType
if (data.uid === this.roomOwnerUid) {
authorType = 3
} else if (data.guard_level !== 0) {
authorType = 1
} else {
authorType = 0
}
let emoticon = null
if (data.dm_type === 1) {
emoticon = data.emoji_img_url
}
data = {
avatarUrl: chat.processAvatarUrl(data.uface),
timestamp: data.timestamp,
authorName: data.uname,
authorType: authorType,
content: data.msg,
privilegeType: data.guard_level,
isGiftDanmaku: chat.isGiftDanmakuByContent(data.msg),
authorLevel: 1,
isNewbie: false,
isMobileVerified: true,
medalLevel: data.fans_medal_wearing_status ? data.fans_medal_level : 0,
id: data.msg_id,
translation: '',
emoticon: emoticon,
}
this.onAddText(data)
}
sendGiftCallback(command) {
if (!this.onAddGift) {
return
}
let data = command.data
if (!data.paid) { // 丢人
return
}
data = {
id: data.msg_id,
avatarUrl: chat.processAvatarUrl(data.uface),
timestamp: data.timestamp,
authorName: data.uname,
totalCoin: data.price * data.gift_num,
giftName: data.gift_name,
num: data.gift_num
}
this.onAddGift(data)
}
async guardCallback(command) {
if (!this.onAddMember) {
return
}
let data = command.data
data = {
id: data.msg_id,
avatarUrl: chat.processAvatarUrl(data.user_info.uface),
timestamp: data.timestamp,
authorName: data.user_info.uname,
privilegeType: data.guard_level
}
this.onAddMember(data)
}
superChatCallback(command) {
if (!this.onAddSuperChat) {
return
}
let data = command.data
data = {
id: data.message_id.toString(),
avatarUrl: chat.processAvatarUrl(data.uface),
timestamp: data.start_time,
authorName: data.uname,
price: data.rmb,
content: data.message,
translation: ''
}
this.onAddSuperChat(data)
}
superChatDelCallback(command) {
if (!this.onDelSuperChat) {
return
}
let ids = []
for (let id of command.data.message_ids) {
ids.push(id.toString())
}
this.onDelSuperChat({ ids })
}
}
const CMD_CALLBACK_MAP = {
LIVE_OPEN_PLATFORM_DM: ChatClientDirectOpenLive.prototype.dmCallback,
LIVE_OPEN_PLATFORM_SEND_GIFT: ChatClientDirectOpenLive.prototype.sendGiftCallback,
LIVE_OPEN_PLATFORM_GUARD: ChatClientDirectOpenLive.prototype.guardCallback,
LIVE_OPEN_PLATFORM_SUPER_CHAT: ChatClientDirectOpenLive.prototype.superChatCallback,
LIVE_OPEN_PLATFORM_SUPER_CHAT_DEL: ChatClientDirectOpenLive.prototype.superChatDelCallback
}

View File

@@ -0,0 +1,187 @@
import axios from 'axios'
import { getUuid4Hex } from '@/utils'
import * as chat from '.'
import * as base from './ChatClientOfficialBase'
import ChatClientOfficialBase from './ChatClientOfficialBase'
export default class ChatClientDirectWeb extends ChatClientOfficialBase {
constructor(roomId) {
super()
this.CMD_CALLBACK_MAP = CMD_CALLBACK_MAP
// 调用initRoom后初始化如果失败使用这里的默认值
this.roomId = roomId
this.roomOwnerUid = -1
this.hostServerList = [
{ host: "broadcastlv.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244 }
]
}
async initRoom() {
let res
try {
res = (await axios.get('/api/room_info', { params: {
roomId: this.roomId
} })).data
} catch {
return true
}
this.roomId = res.roomId
this.roomOwnerUid = res.ownerUid
if (res.hostServerList.length !== 0) {
this.hostServerList = res.hostServerList
}
return true
}
async onBeforeWsConnect() {
// 重连次数太多则重新init_room保险
let reinitPeriod = Math.max(3, (this.hostServerList || []).length)
if (this.retryCount > 0 && this.retryCount % reinitPeriod === 0) {
this.needInitRoom = true
}
return super.onBeforeWsConnect()
}
getWsUrl() {
let hostServer = this.hostServerList[this.retryCount % this.hostServerList.length]
return `wss://${hostServer.host}:${hostServer.wss_port}/sub`
}
sendAuth() {
let authParams = {
uid: 0,
roomid: this.roomId,
protover: 3,
platform: 'web',
type: 2,
buvid: '',
}
this.websocket.send(this.makePacket(authParams, base.OP_AUTH))
}
async danmuMsgCallback(command) {
if (!this.onAddText) {
return
}
let info = command.info
let roomId, medalLevel
if (info[3]) {
roomId = info[3][3]
medalLevel = info[3][0]
} else {
roomId = medalLevel = 0
}
let uid = info[2][0]
let isAdmin = info[2][2]
let privilegeType = info[7]
let authorType
if (uid === this.roomOwnerUid) {
authorType = 3
} else if (isAdmin) {
authorType = 2
} else if (privilegeType !== 0) {
authorType = 1
} else {
authorType = 0
}
let authorName = info[2][1]
let content = info[1]
let data = {
avatarUrl: await chat.getAvatarUrl(uid, authorName),
timestamp: info[0][4] / 1000,
authorName: authorName,
authorType: authorType,
content: content,
privilegeType: privilegeType,
isGiftDanmaku: Boolean(info[0][9]) || chat.isGiftDanmakuByContent(content),
authorLevel: info[4][0],
isNewbie: info[2][5] < 10000,
isMobileVerified: Boolean(info[2][6]),
medalLevel: roomId === this.roomId ? medalLevel : 0,
id: getUuid4Hex(),
translation: '',
emoticon: info[0][13].url || null,
}
this.onAddText(data)
}
sendGiftCallback(command) {
if (!this.onAddGift) {
return
}
let data = command.data
if (data.coin_type !== 'gold') { // 丢人
return
}
data = {
id: getUuid4Hex(),
avatarUrl: chat.processAvatarUrl(data.face),
timestamp: data.timestamp,
authorName: data.uname,
totalCoin: data.total_coin,
giftName: data.giftName,
num: data.num
}
this.onAddGift(data)
}
async guardBuyCallback(command) {
if (!this.onAddMember) {
return
}
let data = command.data
data = {
id: getUuid4Hex(),
avatarUrl: await chat.getAvatarUrl(data.uid, data.username),
timestamp: data.start_time,
authorName: data.username,
privilegeType: data.guard_level
}
this.onAddMember(data)
}
superChatMessageCallback(command) {
if (!this.onAddSuperChat) {
return
}
let data = command.data
data = {
id: data.id.toString(),
avatarUrl: chat.processAvatarUrl(data.user_info.face),
timestamp: data.start_time,
authorName: data.user_info.uname,
price: data.price,
content: data.message,
translation: ''
}
this.onAddSuperChat(data)
}
superChatMessageDeleteCallback(command) {
if (!this.onDelSuperChat) {
return
}
let ids = []
for (let id of command.data.ids) {
ids.push(id.toString())
}
this.onDelSuperChat({ ids })
}
}
const CMD_CALLBACK_MAP = {
DANMU_MSG: ChatClientDirectWeb.prototype.danmuMsgCallback,
SEND_GIFT: ChatClientDirectWeb.prototype.sendGiftCallback,
GUARD_BUY: ChatClientDirectWeb.prototype.guardBuyCallback,
SUPER_CHAT_MESSAGE: ChatClientDirectWeb.prototype.superChatMessageCallback,
SUPER_CHAT_MESSAGE_DELETE: ChatClientDirectWeb.prototype.superChatMessageDeleteCallback
}

View File

@@ -1,40 +1,39 @@
import axios from 'axios'
import { BrotliDecode } from './brotli_decode'
import { getUuid4Hex } from '@/utils'
import * as avatar from '../avatar'
import { inflate } from 'pako'
import * as chat from '..'
const HEADER_SIZE = 16
// const WS_BODY_PROTOCOL_VERSION_NORMAL = 0
// const WS_BODY_PROTOCOL_VERSION_HEARTBEAT = 1
// const WS_BODY_PROTOCOL_VERSION_DEFLATE = 2
const WS_BODY_PROTOCOL_VERSION_BROTLI = 3
export const WS_BODY_PROTOCOL_VERSION_NORMAL = 0
export const WS_BODY_PROTOCOL_VERSION_HEARTBEAT = 1
export const WS_BODY_PROTOCOL_VERSION_DEFLATE = 2
export const WS_BODY_PROTOCOL_VERSION_BROTLI = 3
// const OP_HANDSHAKE = 0
// const OP_HANDSHAKE_REPLY = 1
const OP_HEARTBEAT = 2
const OP_HEARTBEAT_REPLY = 3
// const OP_SEND_MSG = 4
const OP_SEND_MSG_REPLY = 5
// const OP_DISCONNECT_REPLY = 6
const OP_AUTH = 7
const OP_AUTH_REPLY = 8
// const OP_RAW = 9
// const OP_PROTO_READY = 10
// const OP_PROTO_FINISH = 11
// const OP_CHANGE_ROOM = 12
// const OP_CHANGE_ROOM_REPLY = 13
// const OP_REGISTER = 14
// const OP_REGISTER_REPLY = 15
// const OP_UNREGISTER = 16
// const OP_UNREGISTER_REPLY = 17
export const OP_HANDSHAKE = 0
export const OP_HANDSHAKE_REPLY = 1
export const OP_HEARTBEAT = 2
export const OP_HEARTBEAT_REPLY = 3
export const OP_SEND_MSG = 4
export const OP_SEND_MSG_REPLY = 5
export const OP_DISCONNECT_REPLY = 6
export const OP_AUTH = 7
export const OP_AUTH_REPLY = 8
export const OP_RAW = 9
export const OP_PROTO_READY = 10
export const OP_PROTO_FINISH = 11
export const OP_CHANGE_ROOM = 12
export const OP_CHANGE_ROOM_REPLY = 13
export const OP_REGISTER = 14
export const OP_REGISTER_REPLY = 15
export const OP_UNREGISTER = 16
export const OP_UNREGISTER_REPLY = 17
// B站业务自定义OP
// const MinBusinessOp = 1000
// const MaxBusinessOp = 10000
// export const MinBusinessOp = 1000
// export const MaxBusinessOp = 10000
const AUTH_REPLY_CODE_OK = 0
// const AUTH_REPLY_CODE_TOKEN_ERROR = -101
export const AUTH_REPLY_CODE_OK = 0
export const AUTH_REPLY_CODE_TOKEN_ERROR = -101
const HEARTBEAT_INTERVAL = 10 * 1000
const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + (5 * 1000)
@@ -42,14 +41,9 @@ const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + (5 * 1000)
let textEncoder = new TextEncoder()
let textDecoder = new TextDecoder()
export default class ChatClientDirect {
constructor(roomId) {
// 调用initRoom后初始化如果失败使用这里的默认值
this.roomId = roomId
this.roomOwnerUid = 0
this.hostServerList = [
{ host: "broadcastlv.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244 }
]
export default class ChatClientOfficialBase {
constructor() {
this.CMD_CALLBACK_MAP = {}
this.onAddText = null
this.onAddGift = null
@@ -58,6 +52,9 @@ export default class ChatClientDirect {
this.onDelSuperChat = null
this.onUpdateTranslation = null
this.onFatalError = null
this.needInitRoom = true
this.websocket = null
this.retryCount = 0
this.isDestroying = false
@@ -65,8 +62,7 @@ export default class ChatClientDirect {
this.receiveTimeoutTimerId = null
}
async start() {
await this.initRoom()
start() {
this.wsConnect()
}
@@ -78,23 +74,16 @@ export default class ChatClientDirect {
}
async initRoom() {
let res
try {
res = (await axios.get('/api/room_info', { params: {
roomId: this.roomId
} })).data
} catch {
return
}
this.roomId = res.roomId
this.roomOwnerUid = res.ownerUid
if (res.hostServerList.length !== 0) {
this.hostServerList = res.hostServerList
}
throw Error('Not implemented')
}
makePacket(data, operation) {
let body = textEncoder.encode(JSON.stringify(data))
let body
if (typeof data === 'object') {
body = textEncoder.encode(JSON.stringify(data))
} else { // string
body = textEncoder.encode(data)
}
let header = new ArrayBuffer(HEADER_SIZE)
let headerView = new DataView(header)
headerView.setUint32(0, HEADER_SIZE + body.byteLength) // pack_len
@@ -106,29 +95,53 @@ export default class ChatClientDirect {
}
sendAuth() {
let authParams = {
uid: this.roomOwnerUid,
roomid: this.roomId,
protover: 3,
platform: 'web',
type: 2
}
this.websocket.send(this.makePacket(authParams, OP_AUTH))
throw Error('Not implemented')
}
wsConnect() {
async wsConnect() {
if (this.isDestroying) {
return
}
let hostServer = this.hostServerList[this.retryCount % this.hostServerList.length]
const url = `wss://${hostServer.host}:${hostServer.wss_port}/sub`
this.websocket = new WebSocket(url)
await this.onBeforeWsConnect()
if (this.isDestroying) {
return
}
this.websocket = new WebSocket(this.getWsUrl())
this.websocket.binaryType = 'arraybuffer'
this.websocket.onopen = this.onWsOpen.bind(this)
this.websocket.onclose = this.onWsClose.bind(this)
this.websocket.onmessage = this.onWsMessage.bind(this)
}
async onBeforeWsConnect() {
if (!this.needInitRoom) {
return
}
let res
try {
res = await this.initRoom()
} catch (e) {
res = false
console.error('initRoom exception:', e)
if (e instanceof chat.ChatClientFatalError && this.onFatalError) {
this.onFatalError(e)
}
}
if (!res) {
this.onWsClose()
throw Error('initRoom failed')
}
this.needInitRoom = false
}
getWsUrl() {
throw Error('Not implemented')
}
onWsOpen() {
this.sendAuth()
this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), HEARTBEAT_INTERVAL)
@@ -179,11 +192,17 @@ export default class ChatClientDirect {
}
this.retryCount++
console.warn('掉线重连中', this.retryCount)
window.setTimeout(this.wsConnect.bind(this), 1000)
window.setTimeout(this.wsConnect.bind(this), this.getReconnectInterval())
}
getReconnectInterval() {
return Math.min(
1000 + ((this.retryCount - 1) * 2000),
10 * 1000
)
}
onWsMessage(event) {
this.refreshReceiveTimeoutTimer()
if (!(event.data instanceof ArrayBuffer)) {
console.warn('未知的websocket消息类型data=', event.data)
return
@@ -226,6 +245,7 @@ export default class ChatClientDirect {
}
case OP_HEARTBEAT_REPLY: {
// 服务器心跳包,包含人气值,这里没用
this.refreshReceiveTimeoutTimer()
break
}
default: {
@@ -248,6 +268,10 @@ export default class ChatClientDirect {
// 压缩过的先解压
body = BrotliDecode(body)
this.parseWsMessage(body)
} else if (ver == WS_BODY_PROTOCOL_VERSION_DEFLATE) {
// web端已经不用zlib压缩了但是开放平台会用
body = inflate(body)
this.parseWsMessage(body)
} else {
// 没压缩过的直接反序列化
if (body.length !== 0) {
@@ -267,7 +291,7 @@ export default class ChatClientDirect {
body = JSON.parse(textDecoder.decode(body))
if (body.code !== AUTH_REPLY_CODE_OK) {
console.error('认证响应错误body=', body)
// 这里应该重新获取token再重连的但前端没有用到token所以不重新init了
this.needInitRoom = true
this.discardWebsocket()
throw new Error('认证响应错误')
}
@@ -288,131 +312,9 @@ export default class ChatClientDirect {
if (pos != -1) {
cmd = cmd.substr(0, pos)
}
let callback = CMD_CALLBACK_MAP[cmd]
let callback = this.CMD_CALLBACK_MAP[cmd]
if (callback) {
callback.call(this, command)
}
}
async danmuMsgCallback(command) {
if (!this.onAddText) {
return
}
let info = command.info
let roomId, medalLevel
if (info[3]) {
roomId = info[3][3]
medalLevel = info[3][0]
} else {
roomId = medalLevel = 0
}
let uid = info[2][0]
let isAdmin = info[2][2]
let privilegeType = info[7]
let authorType
if (uid === this.roomOwnerUid) {
authorType = 3
} else if (isAdmin) {
authorType = 2
} else if (privilegeType !== 0) {
authorType = 1
} else {
authorType = 0
}
let data = {
avatarUrl: await avatar.getAvatarUrl(uid),
timestamp: info[0][4] / 1000,
authorName: info[2][1],
authorType: authorType,
content: info[1],
privilegeType: privilegeType,
isGiftDanmaku: Boolean(info[0][9]),
authorLevel: info[4][0],
isNewbie: info[2][5] < 10000,
isMobileVerified: Boolean(info[2][6]),
medalLevel: roomId === this.roomId ? medalLevel : 0,
id: getUuid4Hex(),
translation: '',
emoticon: info[0][13].url || null
}
this.onAddText(data)
}
sendGiftCallback(command) {
if (!this.onAddGift) {
return
}
let data = command.data
if (data.coin_type !== 'gold') { // 丢人
return
}
data = {
id: getUuid4Hex(),
avatarUrl: avatar.processAvatarUrl(data.face),
timestamp: data.timestamp,
authorName: data.uname,
totalCoin: data.total_coin,
giftName: data.giftName,
num: data.num
}
this.onAddGift(data)
}
async guardBuyCallback(command) {
if (!this.onAddMember) {
return
}
let data = command.data
data = {
id: getUuid4Hex(),
avatarUrl: await avatar.getAvatarUrl(data.uid),
timestamp: data.start_time,
authorName: data.username,
privilegeType: data.guard_level
}
this.onAddMember(data)
}
superChatMessageCallback(command) {
if (!this.onAddSuperChat) {
return
}
let data = command.data
data = {
id: data.id.toString(),
avatarUrl: avatar.processAvatarUrl(data.user_info.face),
timestamp: data.start_time,
authorName: data.user_info.uname,
price: data.price,
content: data.message,
translation: ''
}
this.onAddSuperChat(data)
}
superChatMessageDeleteCallback(command) {
if (!this.onDelSuperChat) {
return
}
let ids = []
for (let id of command.data.ids) {
ids.push(id.toString())
}
this.onDelSuperChat({ ids })
}
}
const CMD_CALLBACK_MAP = {
DANMU_MSG: ChatClientDirect.prototype.danmuMsgCallback,
SEND_GIFT: ChatClientDirect.prototype.sendGiftCallback,
GUARD_BUY: ChatClientDirect.prototype.guardBuyCallback,
SUPER_CHAT_MESSAGE: ChatClientDirect.prototype.superChatMessageCallback,
SUPER_CHAT_MESSAGE_DELETE: ChatClientDirect.prototype.superChatMessageDeleteCallback
}

View File

@@ -1,3 +1,5 @@
import * as chat from '.'
const COMMAND_HEARTBEAT = 0
const COMMAND_JOIN_ROOM = 1
const COMMAND_ADD_TEXT = 2
@@ -6,6 +8,7 @@ const COMMAND_ADD_MEMBER = 4
const COMMAND_ADD_SUPER_CHAT = 5
const COMMAND_DEL_SUPER_CHAT = 6
const COMMAND_UPDATE_TRANSLATION = 7
const COMMAND_FATAL_ERROR = 8
// const CONTENT_TYPE_TEXT = 0
const CONTENT_TYPE_EMOTICON = 1
@@ -13,8 +16,8 @@ const CONTENT_TYPE_EMOTICON = 1
const RECEIVE_TIMEOUT = 15 * 1000
export default class ChatClientRelay {
constructor(roomId, autoTranslate) {
this.roomId = roomId
constructor(roomKey, autoTranslate) {
this.roomKey = roomKey
this.autoTranslate = autoTranslate
this.onAddText = null
@@ -24,6 +27,8 @@ export default class ChatClientRelay {
this.onDelSuperChat = null
this.onUpdateTranslation = null
this.onFatalError = null
this.websocket = null
this.retryCount = 0
this.isDestroying = false
@@ -54,11 +59,10 @@ export default class ChatClientRelay {
}
onWsOpen() {
this.retryCount = 0
this.websocket.send(JSON.stringify({
cmd: COMMAND_JOIN_ROOM,
data: {
roomId: this.roomId,
roomKey: this.roomKey,
config: {
autoTranslate: this.autoTranslate
}
@@ -95,15 +99,22 @@ export default class ChatClientRelay {
return
}
console.warn(`掉线重连中${++this.retryCount}`)
window.setTimeout(this.wsConnect.bind(this), 1000)
window.setTimeout(this.wsConnect.bind(this), this.getReconnectInterval())
}
getReconnectInterval() {
return Math.min(
1000 + ((this.retryCount - 1) * 2000),
10 * 1000
)
}
onWsMessage(event) {
this.refreshReceiveTimeoutTimer()
let { cmd, data } = JSON.parse(event.data)
switch (cmd) {
case COMMAND_HEARTBEAT: {
this.refreshReceiveTimeoutTimer()
// 不能由定时器触发发心跳包因为浏览器会把不活动页面的定时器调到1分钟以上
this.websocket.send(JSON.stringify({
cmd: COMMAND_HEARTBEAT
@@ -122,21 +133,22 @@ export default class ChatClientRelay {
emoticon = contentTypeParams[0]
}
let content = data[4]
data = {
avatarUrl: data[0],
timestamp: data[1],
authorName: data[2],
authorType: data[3],
content: data[4],
content: content,
privilegeType: data[5],
isGiftDanmaku: Boolean(data[6]),
isGiftDanmaku: Boolean(data[6]) || chat.isGiftDanmakuByContent(content),
authorLevel: data[7],
isNewbie: Boolean(data[8]),
isMobileVerified: Boolean(data[9]),
medalLevel: data[10],
id: data[11],
translation: data[12],
emoticon: emoticon
emoticon: emoticon,
}
this.onAddText(data)
break
@@ -176,6 +188,19 @@ export default class ChatClientRelay {
this.onUpdateTranslation(data)
break
}
case COMMAND_FATAL_ERROR: {
if (!this.onFatalError) {
break
}
let error = new chat.ChatClientFatalError(data.type, data.msg)
this.onFatalError(error)
break
}
}
// 至少成功处理1条消息
if (cmd !== COMMAND_FATAL_ERROR) {
this.retryCount = 0
}
}
}

View File

@@ -1,22 +1,59 @@
import { getUuid4Hex } from '@/utils'
import * as constants from '@/components/ChatRenderer/constants'
import * as avatar from './avatar'
import * as chat from '.'
const NAMES = [
'xfgryujk', 'Simon', 'Il Harper', 'Kinori', 'shugen', 'yuyuyzl', '3Shain', '光羊', '黑炎', 'Misty', '孤梦星影',
'ジョナサン・ジョースター', 'ジョセフ・ジョースター', 'ディオ・ブランドー', '空條承太郎', '博丽灵梦', '雾雨魔理沙',
'Rick Astley'
'光羊',
'黑炎',
'孤梦星影',
'博丽灵梦',
'雾雨魔理沙',
'空條承太郎',
'ディオ・ブランドー',
'ジョセフ・ジョースター',
'ジョナサン・ジョースター',
'Simon',
'Misty',
'Kinori',
'shugen',
'3Shain',
'yuyuyzl',
'xfgryujk',
'Il Harper',
'Rick Astley',
]
const CONTENTS = [
'草', 'kksk', '8888888888', '888888888888888888888888888888', '老板大气,老板身体健康',
'The quick brown fox jumps over the lazy dog', "I can eat glass, it doesn't hurt me",
'我不做人了JOJO', '無駄無駄無駄無駄無駄無駄無駄無駄', '欧啦欧啦欧啦欧啦欧啦欧啦欧啦欧啦', '逃げるんだよォ!',
'嚯,朝我走过来了吗,没有选择逃跑而是主动接近我么', '不要停下来啊', '已经没有什么好怕的了',
'I am the bone of my sword. Steel is my body, and fire is my blood.', '言いたいことがあるんだよ!',
'我忘不掉夏小姐了。如果不是知道了夏小姐,说不定我已经对这个世界没有留恋了', '迷えば、敗れる',
'Farewell, ashen one. May the flame guide thee', '竜神の剣を喰らえ!', '竜が我が敌を喰らう!',
'有一说一,这件事大家懂的都懂,不懂的,说了你也不明白,不如不说', '让我看看', '我柜子动了,我不玩了'
'草',
'让我看看',
'不要停下来啊',
'我不做人了JOJO',
'已经没有什么好怕的了',
'我柜子动了,我不玩了',
'老板大气,老板身体健康',
'我醉提酒游寒山,爽滑慢舔',
'無駄無駄無駄無駄無駄無駄無駄無駄',
'欧啦欧啦欧啦欧啦欧啦欧啦欧啦欧啦',
'所有没好全部康复呀,我的癌也全部康复呀',
'嚯,朝我走过来了吗,没有选择逃跑而是主动接近我么',
'有一说一,这件事大家懂的都懂,不懂的,说了你也不明白,不如不说',
'如来来了吗?如来嘛~他真来了吗?如~来~到底来没来?如来~如来他真来了吗?如来~你看看,来没来?如~来~',
'迷えば、敗れる',
'逃げるんだよォ!',
'竜神の剣を喰らえ!',
'竜が我が敌を喰らう!',
'言いたいことがあるんだよ!',
'知らず知らず隠してた 本当の声を響かせてよほら',
'kksk',
'8888888888',
'Never gonna give you up',
'Never gonna let you down',
'888888888888888888888888888888',
'I am the storm that is approaching',
"I can eat glass, it doesn't hurt me",
'The quick brown fox jumps over the lazy dog',
'Farewell, ashen one. May the flame guide thee',
'I am the bone of my sword. Steel is my body, and fire is my blood.',
]
const EMOTICONS = [
@@ -67,7 +104,7 @@ const MESSAGE_GENERATORS = [
type: constants.MESSAGE_TYPE_TEXT,
message: {
...randGuardInfo(),
avatarUrl: avatar.DEFAULT_AVATAR_URL,
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
content: randomChoose(CONTENTS),
@@ -78,7 +115,7 @@ const MESSAGE_GENERATORS = [
medalLevel: randInt(0, 40),
id: getUuid4Hex(),
translation: '',
emoticon: null
emoticon: null,
}
}
}
@@ -91,7 +128,7 @@ const MESSAGE_GENERATORS = [
type: constants.MESSAGE_TYPE_TEXT,
message: {
...randGuardInfo(),
avatarUrl: avatar.DEFAULT_AVATAR_URL,
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
content: '',
@@ -102,7 +139,7 @@ const MESSAGE_GENERATORS = [
medalLevel: randInt(0, 40),
id: getUuid4Hex(),
translation: '',
emoticon: randomChoose(EMOTICONS)
emoticon: randomChoose(EMOTICONS),
}
}
}
@@ -116,7 +153,7 @@ const MESSAGE_GENERATORS = [
message: {
...randomChoose(GIFT_INFO_LIST),
id: getUuid4Hex(),
avatarUrl: avatar.DEFAULT_AVATAR_URL,
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
num: 1
@@ -132,7 +169,7 @@ const MESSAGE_GENERATORS = [
type: constants.MESSAGE_TYPE_SUPER_CHAT,
message: {
id: getUuid4Hex(),
avatarUrl: avatar.DEFAULT_AVATAR_URL,
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
price: randomChoose(SC_PRICES),
@@ -150,7 +187,7 @@ const MESSAGE_GENERATORS = [
type: constants.MESSAGE_TYPE_MEMBER,
message: {
id: getUuid4Hex(),
avatarUrl: avatar.DEFAULT_AVATAR_URL,
avatarUrl: chat.DEFAULT_AVATAR_URL,
timestamp: new Date().getTime() / 1000,
authorName: randomChoose(NAMES),
privilegeType: randInt(1, 3)
@@ -194,9 +231,6 @@ function randInt(min, max) {
export default class ChatClientTest {
constructor() {
this.minSleepTime = 800
this.maxSleepTime = 1200
this.onAddText = null
this.onAddGift = null
this.onAddMember = null
@@ -204,6 +238,8 @@ export default class ChatClientTest {
this.onDelSuperChat = null
this.onUpdateTranslation = null
this.onFatalError = null
this.timerId = null
}
@@ -219,7 +255,14 @@ export default class ChatClientTest {
}
refreshTimer() {
this.timerId = window.setTimeout(this.onTimeout.bind(this), randInt(this.minSleepTime, this.maxSleepTime))
// 模仿B站的消息间隔模式
let sleepTime
if (randInt(0, 4) == 0) {
sleepTime = randInt(1000, 2000)
} else {
sleepTime = randInt(0, 400)
}
this.timerId = window.setTimeout(this.onTimeout.bind(this), sleepTime)
}
onTimeout() {

View File

@@ -1,28 +0,0 @@
import axios from 'axios'
export const DEFAULT_AVATAR_URL = '//static.hdslb.com/images/member/noface.gif'
export function processAvatarUrl(avatarUrl) {
// 去掉协议兼容HTTP、HTTPS
let m = avatarUrl.match(/(?:https?:)?(.*)/)
if (m) {
avatarUrl = m[1]
}
// 缩小图片加快传输
if (!avatarUrl.endsWith('noface.gif')) {
avatarUrl += '@48w_48h'
}
return avatarUrl
}
export async function getAvatarUrl(uid) {
let res
try {
res = (await axios.get('/api/avatar_url', { params: {
uid: uid
} })).data
} catch {
return DEFAULT_AVATAR_URL
}
return res.avatarUrl
}

View File

@@ -0,0 +1,73 @@
import axios from 'axios'
export const FATAL_ERROR_TYPE_AUTH_CODE_ERROR = 1
export class ChatClientFatalError extends Error {
constructor(type, message) {
super(message)
this.type = type
}
}
export const DEFAULT_AVATAR_URL = '//static.hdslb.com/images/member/noface.gif'
export function processAvatarUrl(avatarUrl) {
// 去掉协议兼容HTTP、HTTPS
let m = avatarUrl.match(/(?:https?:)?(.*)/)
if (m) {
avatarUrl = m[1]
}
return avatarUrl
}
export async function getAvatarUrl(uid, username) {
let res
try {
res = (await axios.get('/api/avatar_url', { params: {
uid: uid,
username: username
} })).data
} catch {
return DEFAULT_AVATAR_URL
}
return res.avatarUrl
}
export async function getTextEmoticons() {
let res
try {
res = (await axios.get('/api/text_emoticon_mappings')).data
} catch {
return []
}
return res.textEmoticons
}
// 开放平台接口不会发送是否是礼物弹幕,只能用内容判断了
const GIFT_DANMAKU_CONTENTS = new Set([
// 红包
'老板大气!点点红包抽礼物',
'老板大气!点点红包抽礼物!',
'点点红包,关注主播抽礼物~',
'喜欢主播加关注,点点红包抽礼物',
'红包抽礼物,开启今日好运!',
'中奖喷雾!中奖喷雾!',
// 节奏风暴
'前方高能预警,注意这不是演习',
'我从未见过如此厚颜无耻之人',
'那万一赢了呢',
'你们城里人真会玩',
'左舷弹幕太薄了',
'要优雅,不要污',
'我选择狗带',
'可爱即正义~~',
'糟了,是心动的感觉!',
'这个直播间已经被我们承包了!',
'妈妈问我为什么跪着看直播 w(゚Д゚)w',
'你们对力量一无所知~( ̄▽ ̄)~',
// 好像花式夸夸还有,不想花钱收集内容了
])
export function isGiftDanmakuByContent(content) {
return GIFT_DANMAKU_CONTENTS.has(content)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -5,7 +5,7 @@
</template>
<script>
import * as avatar from '@/api/chat/avatar'
import * as chat from '@/api/chat'
export default {
name: 'ImgShadow',
@@ -26,8 +26,8 @@ export default {
},
methods: {
onLoadError() {
if (this.showImgUrl !== avatar.DEFAULT_AVATAR_URL) {
this.showImgUrl = avatar.DEFAULT_AVATAR_URL
if (this.showImgUrl !== chat.DEFAULT_AVATAR_URL) {
this.showImgUrl = chat.DEFAULT_AVATAR_URL
}
}
}

View File

@@ -264,15 +264,19 @@ export default {
} else {
let curTime = new Date()
let interval = curTime - this.lastEnqueueTime
// 让发消息速度变化不要太频繁
if (interval > 1000) {
// 真实的进队列时间间隔模式大概是这样2500, 300, 300, 300, 2500, 300, ...
// B站消息有缓冲会一次发多条消息。这里把波峰视为发送了一次真实的WS消息所以要过滤掉间隔太小的
if (interval > 1000 || this.enqueueIntervals.length < 5) {
this.enqueueIntervals.push(interval)
if (this.enqueueIntervals.length > 5) {
this.enqueueIntervals.splice(0, this.enqueueIntervals.length - 5)
}
// 这边估计得尽量大只要不太早把消息缓冲发完就是平滑的。有MESSAGE_MAX_INTERVAL保底不会让消息延迟太大
// 其实可以用单调队列求最大值,偷懒不写了
this.estimatedEnqueueInterval = Math.max(...this.enqueueIntervals)
this.lastEnqueueTime = curTime
}
// 上次入队时间还是要设置,否则会太早把消息缓冲发完,然后较长时间没有新消息
this.lastEnqueueTime = curTime
}
// 把messages分成messageGroup每个组里最多有1个需要平滑的消息

View File

@@ -9,9 +9,15 @@ export default {
home: {
roomIdEmpty: "Room ID can't be empty",
roomIdInteger: 'Room ID must be positive integer',
authCodeEmpty: "Identity code can't be empty",
authCodeFormatError: 'Identity code format error',
useAuthCodeWarning: 'Please prioritize the identity code',
general: 'General',
room: 'Room',
roomId: 'Room ID',
authCode: 'Identity code',
howToGetAuthCode: 'How to get identity code',
showDanmaku: 'Show messages',
showGift: 'Show Super Chats',
showGiftName: 'Show gift name',
@@ -45,6 +51,7 @@ export default {
addEmoticon: 'Add emote',
emoticonFileTooLarge: 'File size is too large. Max size is 1MB',
urlTooLong: 'The room URL is too long, and will be truncated by Livehime (but not by OBS)',
roomUrl: 'Room URL',
enterRoom: 'Enter room',
enterTestRoom: 'Enter test room',
@@ -130,8 +137,9 @@ export default {
},
help: {
help: 'Help',
p1: '1. Copy the room ID from the Bilibili live room webpage',
p2: '2. Enter the room ID into the room ID on the home page. Copy the room URL',
p1_1: '1. Copy the identity code (身份码) from this webpage:',
p1_2: '. NOTE: DO NOT refresh the identity code, unless it is leaked. Once you refresh the identity code, the old one will be invalid',
p2: '2. Enter the identity code into the room configuration on the home page. Copy the room URL',
p3: '3. Generate styles with the style generator. Copy the CSS',
p4: '4. Add browser source in OBS',
p5: '5. Enter the previously copied room URL at URL, and enter the previously copied CSS at custom CSS'

View File

@@ -9,9 +9,15 @@ export default {
home: {
roomIdEmpty: 'ルームのIDを空白にすることはできません',
roomIdInteger: 'ルームは正の整数でなければなりません',
authCodeEmpty: 'アイデンティティコードを空白にすることはできません',
authCodeFormatError: 'アイデンティティコードの形式エラー',
useAuthCodeWarning: 'アイデンティティコードを優先的に使用してください',
general: '常規',
room: 'ルーム',
roomId: 'ルームID',
authCode: 'アイデンティティコード',
howToGetAuthCode: 'アイデンティティコードの取得方法',
showDanmaku: 'コメントを表示する',
showGift: 'スーパーチャットと新メンバーを表示する',
showGiftName: 'ギフト名を表示する',
@@ -45,6 +51,7 @@ export default {
addEmoticon: 'スタンプを追加',
emoticonFileTooLarge: 'ファイルサイズが大きすぎます。最大サイズは1MBです',
urlTooLong: 'ルームのURLが長すぎて、直播姬によって切り詰められますただし、OBSでは切り詰められません',
roomUrl: 'ルームのURL',
enterRoom: 'ルームに入る',
enterTestRoom: 'テストルームに入る',
@@ -130,8 +137,9 @@ export default {
},
help: {
help: 'ヘルプ',
p1: '1. ビリビリの生放送ウェブから生放送ルームIDをこぴーする',
p2: '2. ホームページでコピーしたIDを入力し、ルームのURLをこぴーする',
p1_1: '1. このウェブページからアイデンティティコード(身份码)をコピーして:',
p1_2: '。注意:アイデンティティコードは漏洩していない限り、更新しないでください。アイデンティティコードを更新すると、古いコードは無効になります',
p2: '2. ホームページに先ほどコピーしたアイデンティティコードを入力して、ルームのURLをこぴーする',
p3: '3. スタイルジェネレータでお好みのコメント様子を選び、出力したCSSをコピーする',
p4: '4. OBSでブラウザを新規作成する',
p5: '5. プロパティでこぴーしたURLを入力し、カスタムCSSでスタイルジェネレータのCSSを入力する'

View File

@@ -9,9 +9,15 @@ export default {
home: {
roomIdEmpty: '房间ID不能为空',
roomIdInteger: '房间ID必须为正整数',
authCodeEmpty: '身份码不能为空',
authCodeFormatError: '身份码格式错误',
useAuthCodeWarning: '请优先使用身份码',
general: '常规',
room: '房间',
roomId: '房间ID',
authCode: '身份码',
howToGetAuthCode: '如何获取身份码',
showDanmaku: '显示弹幕',
showGift: '显示打赏和新舰长',
showGiftName: '显示礼物名',
@@ -45,6 +51,7 @@ export default {
addEmoticon: '添加表情',
emoticonFileTooLarge: '文件尺寸太大最大1MB',
urlTooLong: '房间URL太长了会被直播姬截断OBS不会',
roomUrl: '房间URL',
enterRoom: '进入房间',
enterTestRoom: '进入测试房间',
@@ -130,8 +137,9 @@ export default {
},
help: {
help: '帮助',
p1: '1. 从B站直播间网页复制房间ID',
p2: '2. 把房间ID输入到首页的房间ID复制房间URL',
p1_1: '1. 从这个页面复制身份码:',
p1_2: '。注意:不要刷新身份码,除非你的身份码泄露了,因为刷新身份码会使旧的身份码失效',
p2: '2. 把身份码输入到首页的房间配置复制房间URL',
p3: '3. 使用样式生成器生成样式复制CSS',
p4: '4. 在OBS中添加浏览器源',
p5: '5. URL处输入之前复制的房间URL自定义CSS处输入之前复制的CSS'

View File

@@ -3,7 +3,7 @@
<div v-show="isMobile && !hideSidebar" class="drawer-bg" @click="hideSidebar = true"></div>
<el-aside width="230px" class="sidebar-container" :class="{ 'hide-sidebar': hideSidebar }">
<div class="logo-container">
<router-link to="/">
<router-link :to="{ name: 'home' }">
<img src="@/assets/img/logo.png" class="sidebar-logo">
<h1 class="sidebar-title">blivechat</h1>
</router-link>

View File

@@ -1,8 +1,8 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import {
Aside, Autocomplete, Badge, Button, ButtonGroup, Card, Col, ColorPicker, Container, Divider, Form, FormItem, Image,
Input, Main, Menu, MenuItem, Message, Option, OptionGroup, Radio, RadioGroup, Row, Select, Scrollbar,
Alert, Aside, Autocomplete, Badge, Button, ButtonGroup, Card, Col, ColorPicker, Container, Divider, Form, FormItem, Image,
Input, Link, Main, Menu, MenuItem, Message, Option, OptionGroup, Radio, RadioGroup, Row, Select, Scrollbar,
Slider, Submenu, Switch, Table, TableColumn, TabPane, Tabs, Tooltip
} from 'element-ui'
import axios from 'axios'
@@ -20,6 +20,7 @@ axios.defaults.timeout = 10 * 1000
Vue.use(VueRouter)
// 初始化element
Vue.use(Alert)
Vue.use(Aside)
Vue.use(Autocomplete)
Vue.use(Badge)
@@ -34,6 +35,7 @@ Vue.use(Form)
Vue.use(FormItem)
Vue.use(Image)
Vue.use(Input)
Vue.use(Link)
Vue.use(Main)
Vue.use(Menu)
Vue.use(MenuItem)
@@ -65,7 +67,7 @@ const router = new VueRouter({
path: '/',
component: Layout,
children: [
{ path: '', component: Home },
{ path: '', name: 'home', component: Home },
{ path: 'stylegen', name: 'stylegen', component: StyleGenerator },
{ path: 'help', name: 'help', component: Help }
]
@@ -77,15 +79,22 @@ const router = new VueRouter({
props: route => ({ strConfig: route.query })
},
{
path: '/room/:roomId',
path: '/room/:roomKeyValue',
name: 'room',
component: Room,
props(route) {
let roomId = parseInt(route.params.roomId)
if (isNaN(roomId)) {
roomId = null
let roomKeyType = parseInt(route.query.roomKeyType) || 1
if (roomKeyType < 1 || roomKeyType > 2) {
roomKeyType = 1
}
return { roomId, strConfig: route.query }
let roomKeyValue = route.params.roomKeyValue
if (roomKeyType === 1) {
roomKeyValue = parseInt(roomKeyValue) || null
} else {
roomKeyValue = roomKeyValue || null
}
return { roomKeyType, roomKeyValue, strConfig: route.query }
}
},
{ path: '*', component: NotFound }

View File

@@ -41,7 +41,7 @@ export class Trie {
return this.get(key) !== null
}
greedyMatch(str) {
lazyMatch(str) {
let node = this._root
for (let char of str) {
let nextNode = node.children[char]

View File

@@ -1,7 +1,7 @@
<template>
<div>
<h1>{{ $t('help.help') }}</h1>
<p>{{ $t('help.p1') }}</p>
<p>{{ $t('help.p1_1') }} <a href="https://play-live.bilibili.com/" target="_blank">https://play-live.bilibili.com/</a> {{ $t('help.p1_2') }}</p>
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-1.png"></el-image></p>
<p>{{ $t('help.p2') }}</p>
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-2.png"></el-image></p>

View File

@@ -1,17 +1,60 @@
<template>
<div>
<p>
<el-form :model="form" ref="form" label-width="150px" :rules="{
roomId: [
{required: true, message: $t('home.roomIdEmpty'), trigger: 'blur'},
{type: 'integer', min: 1, message: $t('home.roomIdInteger'), trigger: 'blur'}
]
}">
<el-form :model="form" ref="form" label-width="150px">
<el-tabs type="border-card">
<el-tab-pane :label="$t('home.general')">
<el-form-item :label="$t('home.roomId')" required prop="roomId">
<el-input v-model.number="form.roomId" type="number" min="1"></el-input>
<template v-if="form.roomKeyType === 1">
<p>
<el-alert :title="$t('home.useAuthCodeWarning')" type="warning" show-icon :closable="false"></el-alert>
</p>
<el-form-item
:label="$t('home.room')" prop="roomId" :rules="[
{ required: true, message: $t('home.roomIdEmpty') },
{ type: 'integer', min: 1, message: $t('home.roomIdInteger') }
]"
>
<el-row>
<el-col :span="6">
<el-select v-model="form.roomKeyType" style="width: 100%">
<el-option :label="$t('home.authCode')" :value="2"></el-option>
<el-option :label="$t('home.roomId')" :value="1"></el-option>
</el-select>
</el-col>
<el-col :span="18">
<el-input v-model.number="form.roomId" type="number" min="1"></el-input>
</el-col>
</el-row>
</el-form-item>
</template>
<el-form-item v-else-if="form.roomKeyType === 2"
:label="$t('home.room')" prop="authCode" :rules="[
{ required: true, message: $t('home.authCodeEmpty') },
{ pattern: /^[0-9A-Z]{12,14}$/, message: $t('home.authCodeFormatError') }
]"
>
<el-row>
<el-col :span="6">
<el-select v-model="form.roomKeyType" style="width: 100%">
<el-option :label="$t('home.authCode')" :value="2"></el-option>
<el-option :label="$t('home.roomId')" :value="1"></el-option>
</el-select>
</el-col>
<el-col :span="18">
<el-tooltip placement="top-start">
<div slot="content">
<!-- 不知道为什么router-link获取不到$router还是用el-link了不过会有一次丑陋的刷新 -->
<el-link
type="primary" :href="$router.resolve({ name: 'help' }).href"
>{{ $t('home.howToGetAuthCode') }}</el-link>
</div>
<el-input v-model.number="form.authCode"></el-input>
</el-tooltip>
</el-col>
</el-row>
</el-form-item>
<el-row :gutter="20">
<el-col :xs="24" :sm="8">
<el-form-item :label="$t('home.showDanmaku')">
@@ -149,6 +192,9 @@
<p>
<el-card>
<el-form :model="form" label-width="150px">
<p v-if="obsRoomUrl.length > 1024">
<el-alert :title="$t('home.urlTooLong')" type="warning" show-icon :closable="false"></el-alert>
</p>
<el-form-item :label="$t('home.roomUrl')">
<el-input ref="roomUrlInput" readonly :value="obsRoomUrl" style="width: calc(100% - 8em); margin-right: 1em;"></el-input>
<el-button type="primary" icon="el-icon-copy-document" @click="copyUrl"></el-button>
@@ -184,13 +230,25 @@ export default {
},
form: {
...chatConfig.getLocalConfig(),
roomId: parseInt(window.localStorage.roomId || '1')
}
roomKeyType: parseInt(window.localStorage.roomKeyType || '2'),
roomId: parseInt(window.localStorage.roomId || '1'),
authCode: window.localStorage.authCode || '',
},
// 因为$refs.form.validate是异步的所以不能直接用计算属性
// getUnvalidatedRoomUrl -> unvalidatedRoomUrl -> updateRoomUrl -> roomUrl
roomUrl: '',
}
},
computed: {
roomUrl() {
return this.getRoomUrl(false)
roomKeyValue() {
if (this.form.roomKeyType === 1) {
return this.form.roomId
} else {
return this.form.authCode
}
},
unvalidatedRoomUrl() {
return this.getUnvalidatedRoomUrl(false)
},
obsRoomUrl() {
if (this.roomUrl === '') {
@@ -205,13 +263,17 @@ export default {
}
},
watch: {
unvalidatedRoomUrl: 'updateRoomUrl',
roomUrl: _.debounce(function() {
window.localStorage.roomKeyType = this.form.roomKeyType
window.localStorage.roomId = this.form.roomId
window.localStorage.authCode = this.form.authCode
chatConfig.setLocalConfig(this.form)
}, 500)
},
mounted() {
this.updateServerConfig()
this.updateRoomUrl()
},
methods: {
async updateServerConfig() {
@@ -222,6 +284,18 @@ export default {
throw e
}
},
async updateRoomUrl() {
// 防止切换roomKeyType时校验的还是老规则
await this.$nextTick()
try {
await this.$refs.form.validate()
} catch {
this.roomUrl = ''
return
}
// 没有异步的校验规则,应该不需要考虑竞争条件
this.roomUrl = this.unvalidatedRoomUrl
},
addEmoticon() {
this.form.emoticons.push({
@@ -256,28 +330,48 @@ export default {
},
enterRoom() {
window.open(this.roomUrl, `room ${this.form.roomId}`, 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
window.open(this.roomUrl, `room ${this.roomKeyValue}`, 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
},
enterTestRoom() {
window.open(this.getRoomUrl(true), 'test room', 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
window.open(this.getUnvalidatedRoomUrl(true), 'test room', 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
},
getRoomUrl(isTestRoom) {
if (!isTestRoom && this.form.roomId === '') {
return ''
getUnvalidatedRoomUrl(isTestRoom) {
// 重要的字段放在前面,因为如果被截断就连接不了房间了
let frontFields = {
roomKeyType: this.form.roomKeyType
}
let backFields = {
lang: this.$i18n.locale,
emoticons: JSON.stringify(this.form.emoticons)
}
let ignoredNames = new Set(['roomId', 'authCode'])
let query = { ...frontFields }
for (let name in this.form) {
if (!(name in frontFields || name in backFields || ignoredNames.has(name))) {
query[name] = this.form[name]
}
}
Object.assign(query, backFields)
let query = {
...this.form,
emoticons: JSON.stringify(this.form.emoticons),
lang: this.$i18n.locale
}
delete query.roomId
// 去掉和默认值相同的字段缩短URL长度
query = Object.fromEntries(Object.entries(query).filter(
([name, value]) => {
let defaultValue = chatConfig.DEFAULT_CONFIG[name]
if (defaultValue === undefined) {
return true
}
if (typeof defaultValue === 'object') {
defaultValue = JSON.stringify(defaultValue)
}
return value !== defaultValue
}
))
let resolved
if (isTestRoom) {
resolved = this.$router.resolve({ name: 'test_room', query })
} else {
resolved = this.$router.resolve({ name: 'room', params: { roomId: this.form.roomId }, query })
resolved = this.$router.resolve({ name: 'room', params: { roomKeyValue: this.roomKeyValue }, query })
}
return `${window.location.protocol}//${window.location.host}${resolved.href}`
},
@@ -285,6 +379,7 @@ export default {
this.$refs.roomUrlInput.select()
document.execCommand('Copy')
},
exportConfig() {
let cfg = mergeConfig(this.form, chatConfig.DEFAULT_CONFIG)
download(JSON.stringify(cfg, null, 2), 'blivechat.json', 'application/json')
@@ -314,7 +409,9 @@ export default {
chatConfig.sanitizeConfig(cfg)
this.form = {
...cfg,
roomId: this.form.roomId
roomKeyType: this.form.roomKeyType,
roomId: this.form.roomId,
authCode: this.form.authCode
}
}
}

View File

@@ -8,8 +8,10 @@ import { mergeConfig, toBool, toInt } from '@/utils'
import * as trie from '@/utils/trie'
import * as pronunciation from '@/utils/pronunciation'
import * as chatConfig from '@/api/chatConfig'
import * as chat from '@/api/chat'
import ChatClientTest from '@/api/chat/ChatClientTest'
import ChatClientDirect from '@/api/chat/ChatClientDirect'
import ChatClientDirectWeb from '@/api/chat/ChatClientDirectWeb'
import ChatClientDirectOpenLive from '@/api/chat/ChatClientDirectOpenLive'
import ChatClientRelay from '@/api/chat/ChatClientRelay'
import ChatRenderer from '@/components/ChatRenderer'
import * as constants from '@/components/ChatRenderer/constants'
@@ -20,8 +22,12 @@ export default {
ChatRenderer
},
props: {
roomId: {
roomKeyType: {
type: Number,
default: 1
},
roomKeyValue: {
type: [Number, String],
default: null
},
strConfig: {
@@ -33,7 +39,8 @@ export default {
return {
config: chatConfig.deepCloneDefaultConfig(),
chatClient: null,
pronunciationConverter: null
pronunciationConverter: null,
textEmoticons: [], // 官方的文本表情
}
},
computed: {
@@ -59,34 +66,53 @@ export default {
},
emoticonsTrie() {
let res = new trie.Trie()
for (let emoticon of this.config.emoticons) {
if (emoticon.keyword !== '' && emoticon.url !== '') {
res.set(emoticon.keyword, emoticon)
for (let emoticons of [this.config.emoticons, this.textEmoticons]) {
for (let emoticon of emoticons) {
if (emoticon.keyword !== '' && emoticon.url !== '') {
res.set(emoticon.keyword, emoticon)
}
}
}
return res
}
},
mounted() {
this.initConfig()
this.initChatClient()
if (this.config.giftUsernamePronunciation !== '') {
this.pronunciationConverter = new pronunciation.PronunciationConverter()
this.pronunciationConverter.loadDict(this.config.giftUsernamePronunciation)
if (document.visibilityState === 'visible') {
this.init()
} else {
// 当前窗口不可见延迟到可见时加载防止OBS中一次并发太多请求OBS中浏览器不可见时也会加载网页除非显式设置
document.addEventListener('visibilitychange', this.onVisibilityChange)
}
// 提示用户已加载
this.$message({
message: 'Loaded',
duration: '500'
})
},
beforeDestroy() {
document.removeEventListener('visibilitychange', this.onVisibilityChange)
if (this.chatClient) {
this.chatClient.stop()
}
},
methods: {
onVisibilityChange() {
if (document.visibilityState !== 'visible') {
return
}
document.removeEventListener('visibilitychange', this.onVisibilityChange)
this.init()
},
init() {
this.initConfig()
this.initChatClient()
this.initTextEmoticons()
if (this.config.giftUsernamePronunciation !== '') {
this.pronunciationConverter = new pronunciation.PronunciationConverter()
this.pronunciationConverter.loadDict(this.config.giftUsernamePronunciation)
}
// 提示用户已加载
this.$message({
message: 'Loaded',
duration: 500
})
},
initConfig() {
let locale = this.strConfig.lang
if (locale) {
@@ -134,13 +160,19 @@ export default {
}
},
initChatClient() {
if (this.roomId === null) {
if (this.roomKeyValue === null) {
this.chatClient = new ChatClientTest()
} else if (this.config.relayMessagesByServer) {
let roomKey = {
type: this.roomKeyType,
value: this.roomKeyValue
}
this.chatClient = new ChatClientRelay(roomKey, this.config.autoTranslate)
} else {
if (!this.config.relayMessagesByServer) {
this.chatClient = new ChatClientDirect(this.roomId)
if (this.roomKeyType === 1) {
this.chatClient = new ChatClientDirectWeb(this.roomKeyValue)
} else {
this.chatClient = new ChatClientRelay(this.roomId, this.config.autoTranslate)
this.chatClient = new ChatClientDirectOpenLive(this.roomKeyValue)
}
}
this.chatClient.onAddText = this.onAddText
@@ -149,8 +181,12 @@ export default {
this.chatClient.onAddSuperChat = this.onAddSuperChat
this.chatClient.onDelSuperChat = this.onDelSuperChat
this.chatClient.onUpdateTranslation = this.onUpdateTranslation
this.chatClient.onFatalError = this.onFatalError
this.chatClient.start()
},
async initTextEmoticons() {
this.textEmoticons = await chat.getTextEmoticons()
},
start() {
this.chatClient.start()
@@ -247,6 +283,18 @@ export default {
}
this.$refs.renderer.updateMessage(data.id, { translation: data.translation })
},
onFatalError(error) {
this.$message.error({
message: error.toString(),
duration: 10 * 1000
})
this.chatClient.stop()
if (error.type === chat.FATAL_ERROR_TYPE_AUTH_CODE_ERROR) {
// Read The Fucking Manual
this.$router.push({ name: 'help' })
}
},
filterTextMessage(data) {
if (this.config.blockGiftDanmaku && data.isGiftDanmaku) {
@@ -272,7 +320,7 @@ export default {
let blockKeywordsTrie = this.blockKeywordsTrie
for (let i = 0; i < content.length; i++) {
let remainContent = content.substring(i)
if (blockKeywordsTrie.greedyMatch(remainContent) !== null) {
if (blockKeywordsTrie.lazyMatch(remainContent) !== null) {
return false
}
}
@@ -312,8 +360,8 @@ export default {
return richContent
}
// 没有自定义表情,只能是文本
if (this.config.emoticons.length === 0) {
// 没有文本表情,只能是文本
if (this.config.emoticons.length === 0 && this.textEmoticons.length === 0) {
richContent.push({
type: constants.CONTENT_TYPE_TEXT,
text: data.content
@@ -321,13 +369,13 @@ export default {
return richContent
}
// 可能含有自定义表情,需要解析
// 可能含有文本表情,需要解析
let emoticonsTrie = this.emoticonsTrie
let startPos = 0
let pos = 0
while (pos < data.content.length) {
let remainContent = data.content.substring(pos)
let matchEmoticon = emoticonsTrie.greedyMatch(remainContent)
let matchEmoticon = emoticonsTrie.lazyMatch(remainContent)
if (matchEmoticon === null) {
pos++
continue

View File

@@ -1,4 +1,5 @@
const API_BASE_URL = 'http://localhost:12450'
// 不能用localhosthttps://forum.dfinity.org/t/development-workflow-quickly-test-code-modifications/1793/21
const API_BASE_URL = 'http://127.0.0.1:12450'
module.exports = {
devServer: {

114
main.py
View File

@@ -1,55 +1,97 @@
# -*- coding: utf-8 -*-
import argparse
import asyncio
import logging
import logging.handlers
import os
import signal
import sys
import webbrowser
from typing import *
import tornado.ioloop
import tornado.web
import api.chat
import api.main
import api.open_live
import config
import models.database
import services.avatar
import services.chat
import services.translate
import update
import utils.request
logger = logging.getLogger(__name__)
routes = [
(r'/api/server_info', api.main.ServerInfoHandler),
(r'/api/emoticon', api.main.UploadEmoticonHandler),
(r'/api/chat', api.chat.ChatHandler),
(r'/api/room_info', api.chat.RoomInfoHandler),
(r'/api/avatar_url', api.chat.AvatarHandler),
(rf'{api.main.EMOTICON_BASE_URL}/(.*)', tornado.web.StaticFileHandler, {'path': api.main.EMOTICON_UPLOAD_PATH}),
(r'/(.*)', api.main.MainHandler, {'path': config.WEB_ROOT, 'default_filename': 'index.html'})
ROUTES = [
*api.main.ROUTES,
*api.chat.ROUTES,
*api.open_live.ROUTES,
*api.main.LAST_ROUTES,
]
server: Optional[tornado.httpserver.HTTPServer] = None
shut_down_event: Optional[asyncio.Event] = None
async def main():
if not init():
return 1
try:
await run()
finally:
await shut_down()
return 0
def init():
init_signal_handlers()
def main():
args = parse_args()
init_logging(args.debug)
logger.info('App started, initializing')
config.init()
utils.request.init()
models.database.init(args.debug)
services.avatar.init()
services.translate.init()
services.chat.init()
update.check_update()
run_server(args.host, args.port, args.debug)
init_server(args.host, args.port, args.debug)
return server is not None
def init_signal_handlers():
global shut_down_event
shut_down_event = asyncio.Event()
signums = (signal.SIGINT, signal.SIGTERM)
try:
loop = asyncio.get_running_loop()
for signum in signums:
loop.add_signal_handler(signum, on_shut_down_signal)
except NotImplementedError:
# 不太安全但Windows只能用这个
for signum in signums:
signal.signal(signum, on_shut_down_signal)
def on_shut_down_signal(*_args):
shut_down_event.set()
def parse_args():
parser = argparse.ArgumentParser(description='用于OBS的仿YouTube风格的bilibili直播评论栏')
parser.add_argument('--host', help='服务器host默认为127.0.0.1', default='127.0.0.1')
parser.add_argument('--port', help='服务器端口,默认为12450', type=int, default=12450)
parser.add_argument('--host', help='服务器host默认和配置中的一样', default=None)
parser.add_argument('--port', help='服务器端口,默认和配置中的一样', type=int, default=None)
parser.add_argument('--debug', help='调试模式', action='store_true')
return parser.parse_args()
@@ -62,7 +104,6 @@ def init_logging(debug):
)
logging.basicConfig(
format='{asctime} {levelname} [{name}]: {message}',
datefmt='%Y-%m-%d %H:%M:%S',
style='{',
level=logging.INFO if not debug else logging.DEBUG,
handlers=[stream_handler, file_handler]
@@ -72,16 +113,22 @@ def init_logging(debug):
logging.getLogger('tornado.access').setLevel(logging.WARNING)
def run_server(host, port, debug):
def init_server(host, port, debug):
cfg = config.get_config()
if host is None:
host = cfg.host
if port is None:
port = cfg.port
app = tornado.web.Application(
routes,
ROUTES,
websocket_ping_interval=10,
debug=debug,
autoreload=False
)
cfg = config.get_config()
try:
app.listen(
global server
server = app.listen(
port,
host,
xheaders=cfg.tornado_xheaders,
@@ -92,13 +139,30 @@ def run_server(host, port, debug):
logger.warning('Address is used %s:%d', host, port)
return
finally:
url = 'http://localhost/' if port == 80 else f'http://localhost:{port}/'
# 防止更新版本后浏览器加载缓存
url += '?_v=' + update.VERSION
webbrowser.open(url)
if cfg.open_browser_at_startup:
url = 'http://localhost/' if port == 80 else f'http://localhost:{port}/'
webbrowser.open(url)
logger.info('Server started: %s:%d', host, port)
tornado.ioloop.IOLoop.current().start()
async def run():
logger.info('Running event loop')
await shut_down_event.wait()
logger.info('Received shutdown signal')
async def shut_down():
logger.info('Closing server')
server.stop()
await server.close_all_connections()
logger.info('Closing websocket connections')
await services.chat.shut_down()
await utils.request.shut_down()
logger.info('App shut down')
if __name__ == '__main__':
main()
sys.exit(asyncio.run(main()))

View File

@@ -1,11 +1,14 @@
# -*- coding: utf-8 -*-
import datetime
import sqlalchemy
from sqlalchemy.orm import Mapped, mapped_column
import models.database
class BilibiliUser(models.database.OrmBase):
__tablename__ = 'bilibili_users'
uid = sqlalchemy.Column(sqlalchemy.BigInteger, primary_key=True) # 创建表后最好手动改成unsigned
avatar_url = sqlalchemy.Column(sqlalchemy.String(100))
update_time = sqlalchemy.Column(sqlalchemy.DateTime)
uid: Mapped[int] = mapped_column(sqlalchemy.BigInteger, primary_key=True) # 创建表后最好手动改成unsigned
avatar_url: Mapped[str] = mapped_column(sqlalchemy.String(100))
update_time: Mapped[datetime.datetime]

View File

@@ -1,25 +1,32 @@
# -*- coding: utf-8 -*-
from typing import *
import sqlalchemy.ext.declarative
import sqlalchemy.orm
import config
OrmBase = sqlalchemy.ext.declarative.declarative_base()
_engine = None
_DbSession: Optional[Type[sqlalchemy.orm.Session]] = None
_engine: Optional[sqlalchemy.Engine] = None
class OrmBase(sqlalchemy.orm.DeclarativeBase):
pass
def init(_debug):
cfg = config.get_config()
global _engine, _DbSession
# engine = sqlalchemy.create_engine(cfg.database_url, echo=debug)
_engine = sqlalchemy.create_engine(cfg.database_url)
_DbSession = sqlalchemy.orm.sessionmaker(bind=_engine)
global _engine
_engine = sqlalchemy.create_engine(
cfg.database_url,
pool_size=5, # 保持的连接数
max_overflow=5, # 临时的额外连接数
pool_timeout=3, # 连接数达到最大时获取新连接的超时时间
# pool_pre_ping=True, # 获取连接时先检测是否可用
pool_recycle=60 * 60, # 回收超过1小时的连接防止数据库服务器主动断开不活跃的连接
# echo=debug, # 输出SQL语句
)
OrmBase.metadata.create_all(_engine)
def get_session():
return _DbSession()
def get_session() -> sqlalchemy.orm.Session:
return sqlalchemy.orm.Session(_engine)

View File

@@ -1,5 +1,5 @@
aiohttp==3.7.4
Brotli==1.0.9
-r blivedm/requirements.txt
cachetools==5.3.1
pycryptodome==3.10.1
sqlalchemy==1.4.31
sqlalchemy==2.0.19
tornado==6.3.2

View File

@@ -1,14 +1,14 @@
# -*- coding: utf-8 -*-
import asyncio
import dataclasses
import datetime
import hashlib
import logging
import re
import urllib.parse
from typing import *
import aiohttp
import sqlalchemy
import cachetools
import sqlalchemy.exc
import config
@@ -21,240 +21,104 @@ logger = logging.getLogger(__name__)
DEFAULT_AVATAR_URL = '//static.hdslb.com/images/member/noface.gif'
_main_event_loop = asyncio.get_event_loop()
_avatar_fetchers: List['AvatarFetcher'] = []
# user_id -> avatar_url
_avatar_url_cache: Dict[int, str] = {}
_avatar_url_cache: Optional[cachetools.TTLCache] = None
# 正在获取头像的Futureuser_id -> Future
_uid_fetch_future_map: Dict[int, asyncio.Future] = {}
# 正在获取头像的user_id队列
_uid_queue_to_fetch: Optional[asyncio.Queue] = None
# 上次被B站ban时间
_last_fetch_banned_time: Optional[datetime.datetime] = None
# 正在获取头像的任务队列
_task_queue: Optional['asyncio.Queue[FetchTask]'] = None
# wbi密码表
WBI_KEY_INDEX_TABLE = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13
]
# wbi鉴权口令
_wbi_key = ''
@dataclasses.dataclass
class FetchTask:
user_id: int
future: 'asyncio.Future[Optional[str]]'
def init():
cfg = config.get_config()
global _uid_queue_to_fetch
_uid_queue_to_fetch = asyncio.Queue(cfg.fetch_avatar_max_queue_size)
asyncio.ensure_future(_get_avatar_url_from_web_consumer())
global _avatar_url_cache, _task_queue
_avatar_url_cache = cachetools.TTLCache(cfg.avatar_cache_size, 10 * 60)
_task_queue = asyncio.Queue(cfg.fetch_avatar_max_queue_size)
asyncio.get_running_loop().create_task(_do_init())
async def get_avatar_url(user_id):
async def _do_init():
fetchers = [
MedalAnchorAvatarFetcher(3),
UserCardAvatarFetcher(3),
GameUserCenterAvatarFetcher(3),
]
await asyncio.gather(*(fetcher.init() for fetcher in fetchers))
global _avatar_fetchers
_avatar_fetchers = fetchers
async def get_avatar_url(user_id, username) -> str:
avatar_url = await get_avatar_url_or_none(user_id)
if avatar_url is None:
avatar_url = DEFAULT_AVATAR_URL
avatar_url = get_default_avatar_url(user_id, username)
return avatar_url
async def get_avatar_url_or_none(user_id):
def get_default_avatar_url(user_id=0, username=''):
if user_id != 0:
str_to_hash = str(user_id)
elif username != '':
str_to_hash = username
else:
return DEFAULT_AVATAR_URL
id_hash = hashlib.md5(str_to_hash.encode('utf-8')).hexdigest()
return f'//cravatar.cn/avatar/{id_hash}?s=256&d=robohash&f=y'
async def get_avatar_url_or_none(user_id) -> Optional[str]:
if user_id == 0:
return None
avatar_url = get_avatar_url_from_memory(user_id)
# 查内存
avatar_url = _get_avatar_url_from_memory(user_id)
if avatar_url is not None:
return avatar_url
avatar_url = await get_avatar_url_from_database(user_id)
if avatar_url is not None:
# 查数据库
user = await _get_avatar_url_from_database(user_id)
if user is not None:
avatar_url = user.avatar_url
_update_avatar_cache_in_memory(user_id, avatar_url)
# 如果距离数据库上次更新太久,则在后台从接口获取,并更新所有缓存
if (datetime.datetime.now() - user.update_time).days >= 1:
asyncio.create_task(_refresh_avatar_cache_from_web(user_id))
return avatar_url
return await get_avatar_url_from_web(user_id)
# 从接口获取
avatar_url = await _get_avatar_url_from_web(user_id)
if avatar_url is not None:
update_avatar_cache(user_id, avatar_url)
return avatar_url
return None
def get_avatar_url_from_memory(user_id):
return _avatar_url_cache.get(user_id, None)
def get_avatar_url_from_database(user_id) -> Awaitable[Optional[str]]:
return asyncio.get_event_loop().run_in_executor(
None, _do_get_avatar_url_from_database, user_id
)
def _do_get_avatar_url_from_database(user_id):
try:
with models.database.get_session() as session:
user = session.query(bl_models.BilibiliUser).filter(
bl_models.BilibiliUser.uid == user_id
).one_or_none()
if user is None:
return None
avatar_url = user.avatar_url
# 如果离上次更新太久就更新所有缓存
if (datetime.datetime.now() - user.update_time).days >= 3:
def refresh_cache():
_avatar_url_cache.pop(user_id, None)
get_avatar_url_from_web(user_id)
_main_event_loop.call_soon(refresh_cache)
else:
# 否则只更新内存缓存
_update_avatar_cache_in_memory(user_id, avatar_url)
except sqlalchemy.exc.OperationalError:
# SQLite会锁整个文件忽略就行
return None
except sqlalchemy.exc.SQLAlchemyError:
logger.exception('_do_get_avatar_url_from_database failed:')
return None
return avatar_url
def get_avatar_url_from_web(user_id) -> Awaitable[Optional[str]]:
# 如果已有正在获取的future则返回防止重复获取同一个uid
future = _uid_fetch_future_map.get(user_id, None)
if future is not None:
return future
# 否则创建一个获取任务
_uid_fetch_future_map[user_id] = future = _main_event_loop.create_future()
future.add_done_callback(lambda _future: _uid_fetch_future_map.pop(user_id, None))
try:
_uid_queue_to_fetch.put_nowait(user_id)
except asyncio.QueueFull:
future.set_result(None)
return future
async def _get_avatar_url_from_web_consumer():
while True:
try:
user_id = await _uid_queue_to_fetch.get()
future = _uid_fetch_future_map.get(user_id, None)
if future is None:
continue
# 防止在被ban的时候获取
global _last_fetch_banned_time
if _last_fetch_banned_time is not None:
cur_time = datetime.datetime.now()
if (cur_time - _last_fetch_banned_time).total_seconds() < 3 * 60 + 3:
# 3分钟以内被ban解封大约要15分钟
future.set_result(None)
continue
else:
_last_fetch_banned_time = None
asyncio.ensure_future(_get_avatar_url_from_web_coroutine(user_id, future))
# 限制频率防止被B站ban
cfg = config.get_config()
await asyncio.sleep(cfg.fetch_avatar_interval)
except Exception: # noqa
logger.exception('_get_avatar_url_from_web_consumer error:')
async def _get_avatar_url_from_web_coroutine(user_id, future):
try:
avatar_url = await _do_get_avatar_url_from_web(user_id)
except BaseException as e:
future.set_exception(e)
else:
future.set_result(avatar_url)
async def _do_get_avatar_url_from_web(user_id):
global _wbi_key
if _wbi_key == '':
# TODO 判断一下是否正在获取
_wbi_key = await _get_wbi_key()
try:
async with utils.request.http_session.get(
'https://api.bilibili.com/x/space/wbi/acc/info',
headers={
**utils.request.BILIBILI_COMMON_HEADERS,
'Origin': 'https://space.bilibili.com',
'Referer': f'https://space.bilibili.com/{user_id}/'
},
params=_add_wbi_sign({'mid': user_id}),
) as r:
if r.status != 200:
logger.warning('Failed to fetch avatar: status=%d %s uid=%d', r.status, r.reason, user_id)
if r.status == 412:
# 被B站ban了
global _last_fetch_banned_time
_last_fetch_banned_time = datetime.datetime.now()
return None
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
if data['code'] != 0:
# 这里虽然失败但不会被ban一段时间
logger.info('Failed to fetch avatar: code=%d %s uid=%d', data['code'], data['message'], user_id)
if data['code'] == -403:
_wbi_key = ''
return None
avatar_url = process_avatar_url(data['data']['face'])
async def _refresh_avatar_cache_from_web(user_id):
avatar_url = await _get_avatar_url_from_web(user_id)
if avatar_url is None:
return
update_avatar_cache(user_id, avatar_url)
return avatar_url
async def _get_wbi_key():
try:
async with utils.request.http_session.get(
'https://api.bilibili.com/nav',
headers=utils.request.BILIBILI_COMMON_HEADERS,
) as r:
if r.status != 200:
logger.warning('Failed to get wbi key: status=%d %s', r.status, r.reason)
return ''
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
logger.exception('Failed to get wbi key:')
return ''
try:
wbi_img = data['data']['wbi_img']
img_key = wbi_img['img_url'].rpartition('/')[2].partition('.')[0]
sub_key = wbi_img['sub_url'].rpartition('/')[2].partition('.')[0]
except KeyError:
logger.warning('Failed to get wbi key: data=%s', data)
return ''
shuffled_key = img_key + sub_key
wbi_key = []
for index in WBI_KEY_INDEX_TABLE:
if index < len(shuffled_key):
wbi_key.append(shuffled_key[index])
return ''.join(wbi_key)
def update_avatar_cache(user_id, avatar_url):
if user_id == 0:
return
_update_avatar_cache_in_memory(user_id, avatar_url)
_update_avatar_cache_in_database(user_id, avatar_url)
def _add_wbi_sign(params: dict):
if _wbi_key == '':
return params
wts = str(int(datetime.datetime.now().timestamp()))
params_to_sign = {**params, 'wts': wts}
# 按key字典序排序
params_to_sign = {
key: params_to_sign[key]
for key in sorted(params_to_sign.keys())
}
# 过滤一些字符
for key, value in params_to_sign.items():
value = ''.join(
ch
for ch in str(value)
if ch not in "!'()*"
)
params_to_sign[key] = value
str_to_sign = urllib.parse.urlencode(params_to_sign) + _wbi_key
w_rid = hashlib.md5(str_to_sign.encode('utf-8')).hexdigest()
return {
**params,
'wts': wts,
'w_rid': w_rid
}
def update_avatar_cache_if_expired(user_id, avatar_url):
# 内存缓存过期了才更新,减少写入数据库的频率
if _get_avatar_url_from_memory(user_id) is None:
update_avatar_cache(user_id, avatar_url)
def process_avatar_url(avatar_url):
@@ -262,31 +126,54 @@ def process_avatar_url(avatar_url):
m = re.fullmatch(r'(?:https?:)?(.*)', avatar_url)
if m is not None:
avatar_url = m[1]
# 缩小图片加快传输
if not avatar_url.endswith('noface.gif'):
avatar_url += '@48w_48h'
return avatar_url
def update_avatar_cache(user_id, avatar_url):
_update_avatar_cache_in_memory(user_id, avatar_url)
asyncio.get_event_loop().run_in_executor(
None, _update_avatar_cache_in_database, user_id, avatar_url
)
def _get_avatar_url_from_memory(user_id) -> Optional[str]:
return _avatar_url_cache.get(user_id, None)
def _update_avatar_cache_in_memory(user_id, avatar_url):
_avatar_url_cache[user_id] = avatar_url
cfg = config.get_config()
while len(_avatar_url_cache) > cfg.avatar_cache_size:
_avatar_url_cache.pop(next(iter(_avatar_url_cache)), None)
def _update_avatar_cache_in_database(user_id, avatar_url):
def _get_avatar_url_from_database(user_id) -> Awaitable[Optional[bl_models.BilibiliUser]]:
loop = asyncio.get_running_loop()
return loop.run_in_executor(None, _do_get_avatar_url_from_database, user_id)
def _do_get_avatar_url_from_database(user_id) -> Optional[bl_models.BilibiliUser]:
try:
with models.database.get_session() as session:
user = session.query(bl_models.BilibiliUser).filter(
bl_models.BilibiliUser.uid == user_id
user: bl_models.BilibiliUser = session.scalars(
sqlalchemy.select(bl_models.BilibiliUser).filter(
bl_models.BilibiliUser.uid == user_id
)
).one_or_none()
if user is None:
return None
return user
except sqlalchemy.exc.OperationalError:
# SQLite会锁整个文件忽略就行
return None
except sqlalchemy.exc.SQLAlchemyError:
logger.exception('_do_get_avatar_url_from_database failed:')
return None
def _update_avatar_cache_in_database(user_id, avatar_url) -> Awaitable[None]:
return asyncio.get_running_loop().run_in_executor(
None, _do_update_avatar_cache_in_database, user_id, avatar_url
)
def _do_update_avatar_cache_in_database(user_id, avatar_url):
try:
with models.database.get_session() as session:
user = session.scalars(
sqlalchemy.select(bl_models.BilibiliUser).filter(
bl_models.BilibiliUser.uid == user_id
)
).one_or_none()
if user is None:
user = bl_models.BilibiliUser(
@@ -297,7 +184,252 @@ def _update_avatar_cache_in_database(user_id, avatar_url):
user.update_time = datetime.datetime.now()
session.commit()
except (sqlalchemy.exc.OperationalError, sqlalchemy.exc.IntegrityError):
# SQLite会锁整个文件忽略就行另外还有多线程导致ID重复的问题
# SQLite会锁整个文件忽略就行另外还有多线程导致ID重复的问题这里对一致性要求不高就没加for update
pass
except sqlalchemy.exc.SQLAlchemyError:
logger.exception('_update_avatar_cache_in_database failed:')
logger.exception('_do_update_avatar_cache_in_database failed:')
def _get_avatar_url_from_web(user_id) -> Awaitable[Optional[str]]:
# 如果已有正在获取的future则返回防止重复获取同一个uid
future = _uid_fetch_future_map.get(user_id, None)
if future is not None:
return future
# 否则创建一个获取任务
future = asyncio.get_running_loop().create_future()
task = FetchTask(
user_id=user_id,
future=future
)
if not _push_task(task):
future.set_result(None)
return future
_uid_fetch_future_map[user_id] = future
future.add_done_callback(lambda _future: _uid_fetch_future_map.pop(user_id, None))
return future
def _push_task(task: FetchTask):
if not _has_available_avatar_fetcher():
return False
try:
_task_queue.put_nowait(task)
return True
except asyncio.QueueFull:
return False
def _pop_task() -> Awaitable[FetchTask]:
return _task_queue.get()
def _cancel_all_tasks_if_no_available_avatar_fetcher():
if _has_available_avatar_fetcher():
return
logger.warning('No available avatar fetcher')
while not _task_queue.empty():
task = _task_queue.get_nowait()
task.future.set_result(None)
def _has_available_avatar_fetcher():
return any(fetcher.is_available for fetcher in _avatar_fetchers)
class AvatarFetcher:
def __init__(self, query_interval):
self._query_interval = query_interval
self._be_available_event = asyncio.Event()
self._be_available_event.set()
self._cool_down_timer_handle = None
async def init(self):
asyncio.create_task(self._fetch_consumer())
return True
@property
def is_available(self):
return self._cool_down_timer_handle is None
def _on_availability_change(self):
if self.is_available:
self._be_available_event.set()
else:
self._be_available_event.clear()
_cancel_all_tasks_if_no_available_avatar_fetcher()
async def _fetch_consumer(self):
cls_name = type(self).__name__
while True:
try:
if not self.is_available:
logger.info('%s waiting to become available', cls_name)
await self._be_available_event.wait()
logger.info('%s became available', cls_name)
task = await _pop_task()
# 为了简化代码约定只会在_fetch_wrapper里变成不可用所以获取task之后这里还是可用的
assert self.is_available
start_time = datetime.datetime.now()
await self._fetch_wrapper(task)
cost_time = (datetime.datetime.now() - start_time).total_seconds()
# 限制频率防止被B站ban
await asyncio.sleep(self._query_interval - cost_time)
except Exception: # noqa
logger.exception('%s error:', cls_name)
async def _fetch_wrapper(self, task: FetchTask) -> Optional[str]:
try:
avatar_url = await self._do_fetch(task.user_id)
except BaseException as e:
task.future.set_exception(e)
return None
task.future.set_result(avatar_url)
return avatar_url
async def _do_fetch(self, user_id) -> Optional[str]:
raise NotImplementedError
def _cool_down(self, sleep_time):
if self._cool_down_timer_handle is not None:
return
self._cool_down_timer_handle = asyncio.get_running_loop().call_later(
sleep_time, self._on_cool_down_timeout
)
self._on_availability_change()
def _on_cool_down_timeout(self):
self._cool_down_timer_handle = None
self._on_availability_change()
class MedalAnchorAvatarFetcher(AvatarFetcher):
async def _do_fetch(self, user_id) -> Optional[str]:
try:
async with utils.request.http_session.get(
'https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuMedalAnchorInfo',
headers={
**utils.request.BILIBILI_COMMON_HEADERS,
'Origin': 'https://live.bilibili.com',
'Referer': 'https://live.bilibili.com/'
},
params={
'ruid': user_id,
'token': '',
'platform': 'web',
'jsonp': 'jsonp'
}
) as r:
if r.status != 200:
logger.warning(
'MedalAnchorAvatarFetcher failed to fetch avatar: status=%d %s uid=%d',
r.status, r.reason, user_id
)
if r.status == 412:
# 被B站ban了
self._cool_down(3 * 60)
return None
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
code = data['code']
if code != 0:
# 这里虽然失败但不会被ban一段时间
logger.info(
'MedalAnchorAvatarFetcher failed to fetch avatar: code=%d %s uid=%d',
code, data['message'], user_id
)
return None
return process_avatar_url(data['data']['rface'])
class UserCardAvatarFetcher(AvatarFetcher):
async def _do_fetch(self, user_id) -> Optional[str]:
try:
async with utils.request.http_session.get(
'https://api.bilibili.com/x/web-interface/card',
headers={
**utils.request.BILIBILI_COMMON_HEADERS,
'Origin': 'https://t.bilibili.com',
'Referer': 'https://t.bilibili.com/'
},
params={
'mid': user_id,
'photo': 'true'
}
) as r:
if r.status != 200:
logger.warning(
'UserCardAvatarFetcher failed to fetch avatar: status=%d %s uid=%d',
r.status, r.reason, user_id
)
if r.status == 412:
# 被B站ban了
self._cool_down(3 * 60)
return None
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
code = data['code']
if code != 0:
# 这里虽然失败但不会被ban一段时间
logger.info(
'UserCardAvatarFetcher failed to fetch avatar: code=%d %s uid=%d',
code, data['message'], user_id
)
return None
return process_avatar_url(data['data']['card']['face'])
class GameUserCenterAvatarFetcher(AvatarFetcher):
async def _do_fetch(self, user_id) -> Optional[str]:
try:
async with utils.request.http_session.get(
'https://line3-h5-mobile-api.biligame.com/game/center/h5/user/space/info',
headers={
**utils.request.BILIBILI_COMMON_HEADERS,
'Origin': 'https://app.biligame.com',
'Referer': 'https://app.biligame.com/'
},
params={
'uid': user_id,
'sdk_type': '1'
}
) as r:
if r.status != 200:
# 这个接口经常502
logger.info(
'GameUserCenterAvatarFetcher failed to fetch avatar: status=%d %s uid=%d',
r.status, r.reason, user_id
)
if r.status == 412:
# 被B站ban了
self._cool_down(3 * 60)
return None
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
return None
code = data['code']
if code != 0:
# 这里虽然失败但不会被ban一段时间
logger.info(
'GameUserCenterAvatarFetcher failed to fetch avatar: code=%d %s uid=%d',
code, data['message'], user_id
)
return None
return process_avatar_url(data['data']['face'])

View File

@@ -1,11 +1,16 @@
# -*- coding: utf-8 -*-
import asyncio
import enum
import logging
import uuid
from typing import *
import api.chat
import api.open_live as api_open_live
import blivedm.blivedm as blivedm
import blivedm.blivedm.models.open_live as dm_open_models
import blivedm.blivedm.models.web as dm_web_models
import blivedm.blivedm.utils as dm_utils
import config
import services.avatar
import services.translate
@@ -13,6 +18,29 @@ import utils.request
logger = logging.getLogger(__name__)
class RoomKeyType(enum.IntEnum):
ROOM_ID = 1
AUTH_CODE = 2
class RoomKey(NamedTuple):
"""内部用来标识一个房间,由客户端加入房间时传入"""
type: RoomKeyType
value: Union[int, str]
def __str__(self):
res = str(self.value)
if self.type == RoomKeyType.AUTH_CODE:
# 身份码要脱敏
res = '***' + res[-3:]
return res
__repr__ = __str__
# 用于类型标注的类型别名
LiveClientType = Union['WebLiveClient', 'OpenLiveClient']
# 到B站的连接管理
_live_client_manager: Optional['LiveClientManager'] = None
# 到客户端的连接管理
@@ -28,53 +56,194 @@ def init():
_live_msg_handler = LiveMsgHandler()
async def shut_down():
if client_room_manager is not None:
client_room_manager.shut_down()
if _live_client_manager is not None:
await _live_client_manager.shut_down()
class LiveClientManager:
"""管理到B站的连接"""
def __init__(self):
self._live_clients: Dict[int, LiveClient] = {}
self._live_clients: Dict[RoomKey, LiveClientType] = {}
self._close_client_futures: Set[asyncio.Future] = set()
def add_live_client(self, room_id):
if room_id in self._live_clients:
return
logger.info('room=%d creating live client', room_id)
self._live_clients[room_id] = live_client = LiveClient(room_id)
live_client.add_handler(_live_msg_handler)
asyncio.ensure_future(self._init_live_client(live_client))
logger.info('room=%d live client created, %d live clients', room_id, len(self._live_clients))
async def shut_down(self):
while len(self._live_clients) != 0:
room_key = next(iter(self._live_clients))
self.del_live_client(room_key)
async def _init_live_client(self, live_client: 'LiveClient'):
if not await live_client.init_room():
logger.warning('room=%d live client init failed', live_client.tmp_room_id)
self.del_live_client(live_client.tmp_room_id)
await asyncio.gather(*self._close_client_futures, return_exceptions=True)
def add_live_client(self, room_key: RoomKey):
if room_key in self._live_clients:
return
logger.info('room=%d (%d) live client init succeeded', live_client.tmp_room_id, live_client.room_id)
logger.info('room=%s creating live client', room_key)
self._live_clients[room_key] = live_client = self._create_live_client(room_key)
live_client.set_handler(_live_msg_handler)
# 直接启动吧这里不用管init_room失败的情况万一失败了会在on_client_stopped里删除掉这个客户端
live_client.start()
def del_live_client(self, room_id):
live_client = self._live_clients.pop(room_id, None)
logger.info('room=%s live client created, %d live clients', room_key, len(self._live_clients))
@staticmethod
def _create_live_client(room_key: RoomKey):
if room_key.type == RoomKeyType.ROOM_ID:
return WebLiveClient(room_key)
elif room_key.type == RoomKeyType.AUTH_CODE:
return OpenLiveClient(room_key)
raise ValueError(f'Unknown RoomKeyType={room_key.type}')
def del_live_client(self, room_key: RoomKey):
live_client = self._live_clients.pop(room_key, None)
if live_client is None:
return
logger.info('room=%d removing live client', room_id)
live_client.remove_handler(_live_msg_handler)
asyncio.ensure_future(live_client.stop_and_close())
logger.info('room=%d live client removed, %d live clients', room_id, len(self._live_clients))
client_room_manager.del_room(room_id)
logger.info('room=%s removing live client', room_key)
live_client.set_handler(None)
future = asyncio.create_task(live_client.stop_and_close())
self._close_client_futures.add(future)
future.add_done_callback(lambda _future: self._close_client_futures.discard(future))
logger.info('room=%s live client removed, %d live clients', room_key, len(self._live_clients))
client_room_manager.del_room(room_key)
class LiveClient(blivedm.BLiveClient):
RECONNECT_POLICY = dm_utils.make_linear_retry_policy(1, 2, 10)
class WebLiveClient(blivedm.BLiveClient):
HEARTBEAT_INTERVAL = 10
def __init__(self, room_id):
super().__init__(room_id, session=utils.request.http_session, heartbeat_interval=self.HEARTBEAT_INTERVAL)
def __init__(self, room_key: RoomKey):
assert room_key.type == RoomKeyType.ROOM_ID
super().__init__(
room_key.value,
uid=0,
session=utils.request.http_session,
heartbeat_interval=self.HEARTBEAT_INTERVAL,
)
self.set_reconnect_policy(RECONNECT_POLICY)
@property
def tmp_room_id(self):
"""初始化参数传入的房间IDroom_id可能改变这个不会变"""
return self._tmp_room_id
def room_key(self):
return RoomKey(RoomKeyType.ROOM_ID, self.tmp_room_id)
async def init_room(self):
await super().init_room()
res = await super().init_room()
if res:
logger.info('room=%s live client init succeeded, room_id=%d', self.room_key, self.room_id)
else:
logger.info('room=%s live client init with a downgrade, room_id=%d', self.room_key, self.room_id)
# 允许降级
return True
class OpenLiveClient(blivedm.OpenLiveClient):
HEARTBEAT_INTERVAL = 10
def __init__(self, room_key: RoomKey):
assert room_key.type == RoomKeyType.AUTH_CODE
cfg = config.get_config()
super().__init__(
access_key_id=cfg.open_live_access_key_id,
access_key_secret=cfg.open_live_access_key_secret,
app_id=cfg.open_live_app_id,
room_owner_auth_code=room_key.value,
session=utils.request.http_session,
heartbeat_interval=self.HEARTBEAT_INTERVAL,
)
self.set_reconnect_policy(RECONNECT_POLICY)
@property
def room_key(self):
return RoomKey(RoomKeyType.AUTH_CODE, self.room_owner_auth_code)
async def init_room(self):
res = await super().init_room()
if res:
logger.info('room=%s live client init succeeded, room_id=%d', self.room_key, self.room_id)
else:
logger.info('room=%s live client init failed', self.room_key)
return res
async def _start_game(self):
try:
data = await api_open_live.request_open_live_or_common_server(
api_open_live.START_GAME_OPEN_LIVE_URL,
api_open_live.START_GAME_COMMON_SERVER_URL,
{'code': self._room_owner_auth_code, 'app_id': self._app_id}
)
except api_open_live.TransportError:
logger.error('_start_game() failed')
return False
except api_open_live.BusinessError as e:
logger.warning('_start_game() failed')
if e.code == 7007:
# 身份码错误
# 让我看看是哪个混蛋把房间ID、UID当做身份码
logger.info('Auth code error! auth_code=%s', self._room_owner_auth_code)
room = client_room_manager.get_room(self.room_key)
if room is not None:
room.send_cmd_data(api.chat.Command.FATAL_ERROR, {
'type': api.chat.FatalErrorType.AUTH_CODE_ERROR,
'msg': str(e)
})
return False
return self._parse_start_game(data['data'])
async def _end_game(self):
if self._game_id in (None, ''):
return True
try:
await api_open_live.request_open_live_or_common_server(
api_open_live.END_GAME_OPEN_LIVE_URL,
api_open_live.END_GAME_COMMON_SERVER_URL,
{'app_id': self._app_id, 'game_id': self._game_id}
)
except api_open_live.TransportError:
logger.error('room=%d _end_game() failed', self.room_id)
return False
except api_open_live.BusinessError as e:
if e.code in (7000, 7003):
# 项目已经关闭了也算成功
return True
logger.warning('room=%d _end_game() failed', self.room_id)
return False
return True
async def _send_game_heartbeat(self):
if self._game_id in (None, ''):
logger.warning('game=%d _send_game_heartbeat() failed, game_id not found', self._game_id)
return False
# 保存一下防止await之后game_id改变
game_id = self._game_id
try:
await api_open_live.request_open_live_or_common_server(
api_open_live.GAME_HEARTBEAT_OPEN_LIVE_URL,
api_open_live.GAME_HEARTBEAT_COMMON_SERVER_URL,
{'game_id': game_id}
)
except api_open_live.TransportError:
logger.error('room=%d _send_game_heartbeat() failed', self.room_id)
return False
except api_open_live.BusinessError as e:
logger.warning('room=%d _send_game_heartbeat() failed', self.room_id)
if e.code == 7003 and self._game_id == game_id:
# 项目异常关闭,可能是心跳超时,需要重新开启项目
self._need_init_room = True
if self._websocket is not None and not self._websocket.closed:
await self._websocket.close()
return False
return True
@@ -84,75 +253,85 @@ class ClientRoomManager:
DELAY_DEL_ROOM_TIMEOUT = 10
def __init__(self):
self._rooms: Dict[int, ClientRoom] = {}
# room_id -> timer_handle
self._delay_del_timer_handles: Dict[int, asyncio.TimerHandle] = {}
self._rooms: Dict[RoomKey, ClientRoom] = {}
self._delay_del_timer_handles: Dict[RoomKey, asyncio.TimerHandle] = {}
def add_client(self, room_id, client: 'api.chat.ChatHandler'):
room = self._get_or_add_room(room_id)
def shut_down(self):
while len(self._rooms) != 0:
room_key = next(iter(self._rooms))
self.del_room(room_key)
for timer_handle in self._delay_del_timer_handles.values():
timer_handle.cancel()
self._delay_del_timer_handles.clear()
def add_client(self, room_key: RoomKey, client: 'api.chat.ChatHandler'):
room = self._get_or_add_room(room_key)
room.add_client(client)
self._clear_delay_del_timer(room_id)
self._clear_delay_del_timer(room_key)
def del_client(self, room_id, client: 'api.chat.ChatHandler'):
room = self.get_room(room_id)
def del_client(self, room_key: RoomKey, client: 'api.chat.ChatHandler'):
room = self.get_room(room_key)
if room is None:
return
room.del_client(client)
if room.client_count == 0:
self.delay_del_room(room_id, self.DELAY_DEL_ROOM_TIMEOUT)
self.delay_del_room(room_key, self.DELAY_DEL_ROOM_TIMEOUT)
def get_room(self, room_id):
return self._rooms.get(room_id, None)
def get_room(self, room_key: RoomKey):
return self._rooms.get(room_key, None)
def _get_or_add_room(self, room_id):
room = self._rooms.get(room_id, None)
def _get_or_add_room(self, room_key: RoomKey):
room = self._rooms.get(room_key, None)
if room is None:
logger.info('room=%d creating client room', room_id)
self._rooms[room_id] = room = ClientRoom(room_id)
logger.info('room=%d client room created, %d client rooms', room_id, len(self._rooms))
logger.info('room=%s creating client room', room_key)
self._rooms[room_key] = room = ClientRoom(room_key)
logger.info('room=%s client room created, %d client rooms', room_key, len(self._rooms))
_live_client_manager.add_live_client(room_id)
_live_client_manager.add_live_client(room_key)
return room
def del_room(self, room_id):
self._clear_delay_del_timer(room_id)
def del_room(self, room_key: RoomKey):
self._clear_delay_del_timer(room_key)
room = self._rooms.pop(room_id, None)
room = self._rooms.pop(room_key, None)
if room is None:
return
logger.info('room=%d removing client room', room_id)
logger.info('room=%s removing client room', room_key)
room.clear_clients()
logger.info('room=%d client room removed, %d client rooms', room_id, len(self._rooms))
logger.info('room=%s client room removed, %d client rooms', room_key, len(self._rooms))
_live_client_manager.del_live_client(room_id)
_live_client_manager.del_live_client(room_key)
def delay_del_room(self, room_id, timeout):
self._clear_delay_del_timer(room_id)
self._delay_del_timer_handles[room_id] = asyncio.get_event_loop().call_later(
timeout, self._on_delay_del_room, room_id
def delay_del_room(self, room_key: RoomKey, timeout):
self._clear_delay_del_timer(room_key)
self._delay_del_timer_handles[room_key] = asyncio.get_running_loop().call_later(
timeout, self._on_delay_del_room, room_key
)
def _clear_delay_del_timer(self, room_id):
timer_handle = self._delay_del_timer_handles.pop(room_id, None)
def _clear_delay_del_timer(self, room_key: RoomKey):
timer_handle = self._delay_del_timer_handles.pop(room_key, None)
if timer_handle is not None:
timer_handle.cancel()
def _on_delay_del_room(self, room_id):
self._delay_del_timer_handles.pop(room_id, None)
self.del_room(room_id)
def _on_delay_del_room(self, room_key: RoomKey):
self._delay_del_timer_handles.pop(room_key, None)
self.del_room(room_key)
class ClientRoom:
def __init__(self, room_id):
self._room_id = room_id
def __init__(self, room_key: RoomKey):
self._room_key = room_key
self._clients: List[api.chat.ChatHandler] = []
self._auto_translate_count = 0
@property
def room_id(self):
return self._room_id
def room_key(self) -> RoomKey:
return self._room_key
@property
def client_count(self):
@@ -163,11 +342,13 @@ class ClientRoom:
return self._auto_translate_count > 0
def add_client(self, client: 'api.chat.ChatHandler'):
logger.info('room=%d addding client %s', self._room_id, client.request.remote_ip)
logger.info('room=%s addding client %s', self._room_key, client.request.remote_ip)
self._clients.append(client)
if client.auto_translate:
self._auto_translate_count += 1
logger.info('room=%d added client %s, %d clients', self._room_id, client.request.remote_ip,
logger.info('room=%s added client %s, %d clients', self._room_key, client.request.remote_ip,
self.client_count)
def del_client(self, client: 'api.chat.ChatHandler'):
@@ -178,11 +359,13 @@ class ClientRoom:
return
if client.auto_translate:
self._auto_translate_count -= 1
logger.info('room=%d removed client %s, %d clients', self._room_id, client.request.remote_ip,
logger.info('room=%s removed client %s, %d clients', self._room_key, client.request.remote_ip,
self.client_count)
def clear_clients(self):
logger.info('room=%d clearing %d clients', self._room_id, self.client_count)
logger.info('room=%s clearing %d clients', self._room_key, self.client_count)
for client in self._clients:
client.close()
self._clients.clear()
@@ -200,92 +383,21 @@ class ClientRoom:
class LiveMsgHandler(blivedm.BaseHandler):
# 重新定义XXX_callback是为了减少对字段名的依赖防止B站改字段名
def __danmu_msg_callback(self, client: LiveClient, command: dict):
info = command['info']
if len(info[3]) != 0:
medal_level = info[3][0]
medal_room_id = info[3][3]
def on_client_stopped(self, client: LiveClientType, exception: Optional[Exception]):
_live_client_manager.del_live_client(client.room_key)
def _on_danmaku(self, client: WebLiveClient, message: dm_web_models.DanmakuMessage):
asyncio.create_task(self.__on_danmaku(client, message))
async def __on_danmaku(self, client: WebLiveClient, message: dm_web_models.DanmakuMessage):
avatar_url = message.face
if avatar_url != '':
services.avatar.update_avatar_cache_if_expired(message.uid, avatar_url)
else:
medal_level = 0
medal_room_id = 0
# 先异步调用再获取房间,因为返回时房间可能已经不存在了
avatar_url = await services.avatar.get_avatar_url(message.uid, message.uname)
message = blivedm.DanmakuMessage(
timestamp=info[0][4],
msg_type=info[0][9],
dm_type=info[0][12],
emoticon_options=info[0][13],
msg=info[1],
uid=info[2][0],
uname=info[2][1],
admin=info[2][2],
urank=info[2][5],
mobile_verify=info[2][6],
medal_level=medal_level,
medal_room_id=medal_room_id,
user_level=info[4][0],
privilege_type=info[7],
)
return self._on_danmaku(client, message)
def __send_gift_callback(self, client: LiveClient, command: dict):
data = command['data']
message = blivedm.GiftMessage(
gift_name=data['giftName'],
num=data['num'],
uname=data['uname'],
face=data['face'],
uid=data['uid'],
timestamp=data['timestamp'],
coin_type=data['coin_type'],
total_coin=data['total_coin'],
)
return self._on_gift(client, message)
def __guard_buy_callback(self, client: LiveClient, command: dict):
data = command['data']
message = blivedm.GuardBuyMessage(
uid=data['uid'],
username=data['username'],
guard_level=data['guard_level'],
start_time=data['start_time'],
)
return self._on_buy_guard(client, message)
def __super_chat_message_callback(self, client: LiveClient, command: dict):
data = command['data']
message = blivedm.SuperChatMessage(
price=data['price'],
message=data['message'],
start_time=data['start_time'],
id=data['id'],
uid=data['uid'],
uname=data['user_info']['uname'],
face=data['user_info']['face'],
)
return self._on_super_chat(client, message)
_CMD_CALLBACK_DICT = {
**blivedm.BaseHandler._CMD_CALLBACK_DICT,
'DANMU_MSG': __danmu_msg_callback,
'SEND_GIFT': __send_gift_callback,
'GUARD_BUY': __guard_buy_callback,
'SUPER_CHAT_MESSAGE': __super_chat_message_callback
}
async def _on_danmaku(self, client: LiveClient, message: blivedm.DanmakuMessage):
asyncio.ensure_future(self.__on_danmaku(client, message))
async def __on_danmaku(self, client: LiveClient, message: blivedm.DanmakuMessage):
# 先异步调用再获取房间,因为返回时房间可能已经不存在了
avatar_url = await services.avatar.get_avatar_url(message.uid)
room = client_room_manager.get_room(client.tmp_room_id)
room = client_room_manager.get_room(client.room_key)
if room is None:
return
@@ -307,7 +419,9 @@ class LiveMsgHandler(blivedm.BaseHandler):
content_type = api.chat.ContentType.TEXT
content_type_params = None
need_translate = self._need_translate(message.msg, room)
need_translate = (
content_type != api.chat.ContentType.EMOTICON and self._need_translate(message.msg, room, client)
)
if need_translate:
translation = services.translate.get_translation_from_cache(message.msg)
if translation is None:
@@ -338,18 +452,17 @@ class LiveMsgHandler(blivedm.BaseHandler):
))
if need_translate:
await self._translate_and_response(message.msg, room.room_id, msg_id)
await self._translate_and_response(message.msg, room.room_key, msg_id)
async def _on_gift(self, client: LiveClient, message: blivedm.GiftMessage):
def _on_gift(self, client: WebLiveClient, message: dm_web_models.GiftMessage):
avatar_url = services.avatar.process_avatar_url(message.face)
# 服务器白给的头像URL直接缓存
services.avatar.update_avatar_cache(message.uid, avatar_url)
services.avatar.update_avatar_cache_if_expired(message.uid, avatar_url)
# 丢人
if message.coin_type != 'gold':
return
room = client_room_manager.get_room(client.tmp_room_id)
room = client_room_manager.get_room(client.room_key)
if room is None:
return
@@ -363,15 +476,15 @@ class LiveMsgHandler(blivedm.BaseHandler):
'num': message.num
})
async def _on_buy_guard(self, client: LiveClient, message: blivedm.GuardBuyMessage):
asyncio.ensure_future(self.__on_buy_guard(client, message))
def _on_buy_guard(self, client: WebLiveClient, message: dm_web_models.GuardBuyMessage):
asyncio.create_task(self.__on_buy_guard(client, message))
@staticmethod
async def __on_buy_guard(client: LiveClient, message: blivedm.GuardBuyMessage):
async def __on_buy_guard(client: WebLiveClient, message: dm_web_models.GuardBuyMessage):
# 先异步调用再获取房间,因为返回时房间可能已经不存在了
avatar_url = await services.avatar.get_avatar_url(message.uid)
avatar_url = await services.avatar.get_avatar_url(message.uid, message.username)
room = client_room_manager.get_room(client.tmp_room_id)
room = client_room_manager.get_room(client.room_key)
if room is None:
return
@@ -383,16 +496,15 @@ class LiveMsgHandler(blivedm.BaseHandler):
'privilegeType': message.guard_level
})
async def _on_super_chat(self, client: LiveClient, message: blivedm.SuperChatMessage):
def _on_super_chat(self, client: WebLiveClient, message: dm_web_models.SuperChatMessage):
avatar_url = services.avatar.process_avatar_url(message.face)
# 服务器白给的头像URL直接缓存
services.avatar.update_avatar_cache(message.uid, avatar_url)
services.avatar.update_avatar_cache_if_expired(message.uid, avatar_url)
room = client_room_manager.get_room(client.tmp_room_id)
room = client_room_manager.get_room(client.room_key)
if room is None:
return
need_translate = self._need_translate(message.message, room)
need_translate = self._need_translate(message.message, room, client)
if need_translate:
translation = services.translate.get_translation_from_cache(message.message)
if translation is None:
@@ -415,10 +527,12 @@ class LiveMsgHandler(blivedm.BaseHandler):
})
if need_translate:
asyncio.ensure_future(self._translate_and_response(message.message, room.room_id, msg_id))
asyncio.create_task(self._translate_and_response(
message.message, room.room_key, msg_id, services.translate.Priority.HIGH
))
async def _on_super_chat_delete(self, client: LiveClient, message: blivedm.SuperChatDeleteMessage):
room = client_room_manager.get_room(client.tmp_room_id)
def _on_super_chat_delete(self, client: WebLiveClient, message: dm_web_models.SuperChatDeleteMessage):
room = client_room_manager.get_room(client.room_key)
if room is None:
return
@@ -427,22 +541,22 @@ class LiveMsgHandler(blivedm.BaseHandler):
})
@staticmethod
def _need_translate(text, room: ClientRoom):
def _need_translate(text, room: ClientRoom, client: LiveClientType):
cfg = config.get_config()
return (
cfg.enable_translate
and room.need_translate
and (not cfg.allow_translate_rooms or room.room_id in cfg.allow_translate_rooms)
and (not cfg.allow_translate_rooms or client.room_id in cfg.allow_translate_rooms)
and services.translate.need_translate(text)
)
@staticmethod
async def _translate_and_response(text, room_id, msg_id):
translation = await services.translate.translate(text)
async def _translate_and_response(text, room_key: RoomKey, msg_id, priority=services.translate.Priority.NORMAL):
translation = await services.translate.translate(text, priority)
if translation is None:
return
room = client_room_manager.get_room(room_id)
room = client_room_manager.get_room(room_key)
if room is None:
return
@@ -454,3 +568,141 @@ class LiveMsgHandler(blivedm.BaseHandler):
translation
)
)
#
# 开放平台消息
#
def _on_open_live_danmaku(self, client: OpenLiveClient, message: dm_open_models.DanmakuMessage):
avatar_url = message.uface
services.avatar.update_avatar_cache_if_expired(message.uid, avatar_url)
room = client_room_manager.get_room(client.room_key)
if room is None:
return
if message.uid == client.room_owner_uid:
author_type = 3 # 主播
elif message.guard_level != 0: # 1总督2提督3舰长
author_type = 1 # 舰队
else:
author_type = 0
if message.dm_type == 1:
content_type = api.chat.ContentType.EMOTICON
content_type_params = api.chat.make_emoticon_params(message.emoji_img_url)
else:
content_type = api.chat.ContentType.TEXT
content_type_params = None
need_translate = (
content_type != api.chat.ContentType.EMOTICON and self._need_translate(message.msg, room, client)
)
if need_translate:
translation = services.translate.get_translation_from_cache(message.msg)
if translation is None:
# 没有缓存,需要后面异步翻译后通知
translation = ''
else:
need_translate = False
else:
translation = ''
room.send_cmd_data(api.chat.Command.ADD_TEXT, api.chat.make_text_message_data(
avatar_url=avatar_url,
timestamp=message.timestamp,
author_name=message.uname,
author_type=author_type,
content=message.msg,
privilege_type=message.guard_level,
medal_level=0 if not message.fans_medal_wearing_status else message.fans_medal_level,
id_=message.msg_id,
translation=translation,
content_type=content_type,
content_type_params=content_type_params,
))
if need_translate:
asyncio.create_task(self._translate_and_response(message.msg, room.room_key, message.msg_id))
def _on_open_live_gift(self, client: OpenLiveClient, message: dm_open_models.GiftMessage):
avatar_url = services.avatar.process_avatar_url(message.uface)
services.avatar.update_avatar_cache_if_expired(message.uid, avatar_url)
# 丢人
if not message.paid:
return
room = client_room_manager.get_room(client.room_key)
if room is None:
return
room.send_cmd_data(api.chat.Command.ADD_GIFT, {
'id': message.msg_id,
'avatarUrl': avatar_url,
'timestamp': message.timestamp,
'authorName': message.uname,
'totalCoin': message.price * message.gift_num,
'giftName': message.gift_name,
'num': message.gift_num
})
def _on_open_live_buy_guard(self, client: OpenLiveClient, message: dm_open_models.GuardBuyMessage):
avatar_url = message.user_info.uface
services.avatar.update_avatar_cache_if_expired(message.user_info.uid, avatar_url)
room = client_room_manager.get_room(client.room_key)
if room is None:
return
room.send_cmd_data(api.chat.Command.ADD_MEMBER, {
'id': message.msg_id,
'avatarUrl': avatar_url,
'timestamp': message.timestamp,
'authorName': message.user_info.uname,
'privilegeType': message.guard_level
})
def _on_open_live_super_chat(self, client: OpenLiveClient, message: dm_open_models.SuperChatMessage):
avatar_url = services.avatar.process_avatar_url(message.uface)
services.avatar.update_avatar_cache_if_expired(message.uid, avatar_url)
room = client_room_manager.get_room(client.room_key)
if room is None:
return
need_translate = self._need_translate(message.message, room, client)
if need_translate:
translation = services.translate.get_translation_from_cache(message.message)
if translation is None:
# 没有缓存,需要后面异步翻译后通知
translation = ''
else:
need_translate = False
else:
translation = ''
msg_id = str(message.message_id)
room.send_cmd_data(api.chat.Command.ADD_SUPER_CHAT, {
'id': msg_id,
'avatarUrl': avatar_url,
'timestamp': message.start_time,
'authorName': message.uname,
'price': message.rmb,
'content': message.message,
'translation': translation
})
if need_translate:
asyncio.create_task(self._translate_and_response(
message.message, room.room_key, msg_id, services.translate.Priority.HIGH
))
def _on_open_live_super_chat_delete(self, client: OpenLiveClient, message: dm_open_models.SuperChatDeleteMessage):
room = client_room_manager.get_room(client.room_key)
if room is None:
return
room.send_cmd_data(api.chat.Command.DEL_SUPER_CHAT, {
'ids': list(map(str, message.message_ids))
})

View File

@@ -1,7 +1,9 @@
# -*- coding: utf-8 -*-
import asyncio
import base64
import dataclasses
import datetime
import enum
import functools
import hashlib
import hmac
@@ -14,6 +16,7 @@ from typing import *
import Crypto.Cipher.AES as cry_aes # noqa
import Crypto.Util.Padding as cry_pad # noqa
import aiohttp
import cachetools
import config
import utils.request
@@ -27,13 +30,33 @@ NO_TRANSLATE_TEXTS = {
_translate_providers: List['TranslateProvider'] = []
# text -> res
_translate_cache: Dict[str, str] = {}
_translate_cache: Optional[cachetools.LRUCache] = None
# 正在翻译的Futuretext -> Future
_text_future_map: Dict[str, asyncio.Future] = {}
# 正在翻译的任务队列,索引是优先级
_task_queues: List['asyncio.Queue[TranslateTask]'] = []
class Priority(enum.IntEnum):
HIGH = 0
NORMAL = 1
@dataclasses.dataclass
class TranslateTask:
priority: Priority
text: str
future: 'asyncio.Future[Optional[str]]'
remain_retry_count: int
def init():
asyncio.ensure_future(_do_init())
cfg = config.get_config()
global _translate_cache, _task_queues
_translate_cache = cachetools.LRUCache(cfg.translation_cache_size)
# 总队列长度会超过translate_max_queue_size不用这么严格
_task_queues = [asyncio.Queue(cfg.translate_max_queue_size) for _ in range(len(Priority))]
asyncio.get_running_loop().create_task(_do_init())
async def _do_init():
@@ -54,19 +77,17 @@ def create_translate_provider(cfg):
type_ = cfg['type']
if type_ == 'TencentTranslateFree':
return TencentTranslateFree(
cfg['query_interval'], cfg['max_queue_size'], cfg['source_language'],
cfg['target_language']
cfg['query_interval'], cfg['source_language'], cfg['target_language']
)
elif type_ == 'TencentTranslate':
return TencentTranslate(
cfg['query_interval'], cfg['max_queue_size'], cfg['source_language'],
cfg['target_language'], cfg['secret_id'], cfg['secret_key'],
cfg['region']
cfg['query_interval'], cfg['source_language'], cfg['target_language'],
cfg['secret_id'], cfg['secret_key'], cfg['region']
)
elif type_ == 'BaiduTranslate':
return BaiduTranslate(
cfg['query_interval'], cfg['max_queue_size'], cfg['source_language'],
cfg['target_language'], cfg['app_id'], cfg['secret']
cfg['query_interval'], cfg['source_language'], cfg['target_language'],
cfg['app_id'], cfg['secret']
)
return None
@@ -93,14 +114,14 @@ def get_translation_from_cache(text):
return _translate_cache.get(key, None)
def translate(text) -> Awaitable[Optional[str]]:
def translate(text, priority=Priority.NORMAL) -> Awaitable[Optional[str]]:
key = text.strip().lower()
# 如果已有正在翻译的future则返回防止重复翻译
future = _text_future_map.get(key, None)
if future is not None:
return future
# 否则创建一个翻译任务
future = asyncio.get_event_loop().create_future()
future = asyncio.get_running_loop().create_future()
# 查缓存
res = _translate_cache.get(key, None)
@@ -108,25 +129,18 @@ def translate(text) -> Awaitable[Optional[str]]:
future.set_result(res)
return future
# 负载均衡找等待时间最少的provider
min_wait_time = None
min_wait_time_provider = None
for provider in _translate_providers:
if not provider.is_available:
continue
wait_time = provider.wait_time
if min_wait_time is None or wait_time < min_wait_time:
min_wait_time = wait_time
min_wait_time_provider = provider
# 没有可用的
if min_wait_time_provider is None:
task = TranslateTask(
priority=priority,
text=text,
future=future,
remain_retry_count=3 if priority == Priority.HIGH else 1
)
if not _push_task(task):
future.set_result(None)
return future
_text_future_map[key] = future
future.add_done_callback(functools.partial(_on_translate_done, key))
min_wait_time_provider.translate(text, future)
return future
@@ -140,76 +154,149 @@ def _on_translate_done(key, future):
if res is None:
return
_translate_cache[key] = res
cfg = config.get_config()
while len(_translate_cache) > cfg.translation_cache_size:
_translate_cache.pop(next(iter(_translate_cache)), None)
def _push_task(task: TranslateTask):
if not _has_available_translate_provider():
return False
queue = _task_queues[task.priority]
if not queue.full():
queue.put_nowait(task)
return True
if task.priority != Priority.HIGH:
return False
# 高优先级的尝试降级,挤掉低优先级的任务
queue = _task_queues[Priority.NORMAL]
if queue.full():
lower_task = queue.get_nowait()
lower_task.future.set_result(None)
queue.put_nowait(task)
return True
async def _pop_task() -> TranslateTask:
# 按优先级遍历,看是否已经有任务
for queue in _task_queues:
if not queue.empty():
return queue.get_nowait()
done_future_set, pending_future_set = await asyncio.wait(
[asyncio.create_task(queue.get()) for queue in _task_queues],
return_when=asyncio.FIRST_COMPLETED
)
for future in pending_future_set:
future.cancel()
# 如果有多个队列都取到任务了,只返回优先级最高的那一个,剩下的放回队列
assert len(done_future_set) != 0
tasks = [await future for future in done_future_set]
if len(tasks) > 1:
tasks.sort(key=lambda task_: task_.priority)
res = None
for task in tasks:
if res is None:
res = task
continue
if not _push_task(task):
task.future.set_result(None)
return res
def _cancel_all_tasks_if_no_available_translate_provider():
if _has_available_translate_provider():
return
logger.warning('No available translate provider')
for queue in _task_queues:
while not queue.empty():
task = queue.get_nowait()
task.future.set_result(None)
def _has_available_translate_provider():
return any(provider.is_available for provider in _translate_providers)
class TranslateProvider:
async def init(self):
return True
@property
def is_available(self):
return True
@property
def wait_time(self):
return 0
def translate(self, text, future):
raise NotImplementedError
class FlowControlTranslateProvider(TranslateProvider):
def __init__(self, query_interval, max_queue_size):
def __init__(self, query_interval):
self._query_interval = query_interval
# (text, future)
self._text_queue = asyncio.Queue(max_queue_size)
self._be_available_event = asyncio.Event()
self._be_available_event.set()
async def init(self):
asyncio.ensure_future(self._translate_consumer())
asyncio.create_task(self._translate_consumer())
return True
@property
def is_available(self):
return not self._text_queue.full()
return True
@property
def wait_time(self):
return self._text_queue.qsize() * self._query_interval
def translate(self, text, future):
try:
self._text_queue.put_nowait((text, future))
except asyncio.QueueFull:
future.set_result(None)
def _on_availability_change(self):
if self.is_available:
self._be_available_event.set()
else:
self._be_available_event.clear()
_cancel_all_tasks_if_no_available_translate_provider()
async def _translate_consumer(self):
cls_name = type(self).__name__
while True:
try:
text, future = await self._text_queue.get()
asyncio.ensure_future(self._translate_coroutine(text, future))
if not self.is_available:
logger.info('%s waiting to become available', cls_name)
await self._be_available_event.wait()
logger.info('%s became available', cls_name)
task = await _pop_task()
# 为了简化代码约定只会在_translate_wrapper里变成不可用所以获取task之后这里还是可用的
assert self.is_available
start_time = datetime.datetime.now()
await self._translate_wrapper(task)
cost_time = (datetime.datetime.now() - start_time).total_seconds()
# 频率限制
await asyncio.sleep(self._query_interval)
await asyncio.sleep(self._query_interval - cost_time)
except Exception: # noqa
logger.exception('FlowControlTranslateProvider error:')
logger.exception('%s error:', cls_name)
async def _translate_coroutine(self, text, future):
async def _translate_wrapper(self, task: TranslateTask) -> Optional[str]:
try:
res = await self._do_translate(text)
exc = None
task.remain_retry_count -= 1
res = await self._do_translate(task.text)
except BaseException as e:
future.set_exception(e)
else:
future.set_result(res)
exc = e
res = None
if res is not None:
task.future.set_result(res)
return res
async def _do_translate(self, text):
if task.remain_retry_count > 0:
# 还可以重试则放回队列
if not _push_task(task):
task.future.set_result(None)
else:
# 否则设置异常或None结果
if exc is not None:
task.future.set_exception(exc)
else:
task.future.set_result(None)
return None
async def _do_translate(self, text) -> Optional[str]:
raise NotImplementedError
class TencentTranslateFree(FlowControlTranslateProvider):
def __init__(self, query_interval, max_queue_size, source_language, target_language):
super().__init__(query_interval, max_queue_size)
class TencentTranslateFree(TranslateProvider):
def __init__(self, query_interval, source_language, target_language):
super().__init__(query_interval)
self._be_available_event.clear() # _do_init之后才可用
self._source_language = source_language
self._target_language = target_language
@@ -224,9 +311,7 @@ class TencentTranslateFree(FlowControlTranslateProvider):
async def init(self):
if not await super().init():
return False
if not await self._do_init():
return False
self._reinit_future = asyncio.ensure_future(self._reinit_coroutine())
self._reinit_future = asyncio.create_task(self._reinit_coroutine())
return True
async def _do_init(self):
@@ -244,7 +329,7 @@ class TencentTranslateFree(FlowControlTranslateProvider):
self._server_time_delta = int((datetime.datetime.now().timestamp() - server_time) * 1000)
except (KeyError, ValueError):
self._server_time_delta = 0
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
except (aiohttp.ClientError, asyncio.TimeoutError):
logger.exception('TencentTranslateFree init error:')
return False
@@ -279,7 +364,7 @@ class TencentTranslateFree(FlowControlTranslateProvider):
reauthuri, r.status, r.reason)
return False
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
except (aiohttp.ClientError, asyncio.TimeoutError):
logger.exception('TencentTranslateFree init error:')
return False
@@ -296,35 +381,35 @@ class TencentTranslateFree(FlowControlTranslateProvider):
self._uc_iv = uc_iv
self._qtv = qtv
self._qtk = qtk
self._on_availability_change()
return True
async def _reinit_coroutine(self):
try:
while True:
await asyncio.sleep(30)
logger.debug('TencentTranslateFree reinit')
asyncio.ensure_future(self._do_init())
except asyncio.CancelledError:
pass
while True:
logger.debug('TencentTranslateFree reinit')
start_time = datetime.datetime.now()
try:
await self._do_init()
except Exception: # noqa
pass
cost_time = (datetime.datetime.now() - start_time).total_seconds()
await asyncio.sleep(30 - cost_time)
@property
def is_available(self):
return '' not in (self._uc_key, self._uc_iv, self._qtv, self._qtk) and super().is_available
async def _translate_coroutine(self, text, future):
try:
res = await self._do_translate(text)
except BaseException as e:
future.set_exception(e)
self._on_fail()
return
future.set_result(res)
if res is None:
self._on_fail()
else:
async def _translate_wrapper(self, task: TranslateTask) -> Optional[str]:
res = await super()._translate_wrapper(task)
if res is not None:
self._fail_count = 0
else:
self._on_fail()
return res
async def _do_translate(self, text):
async def _do_translate(self, text) -> Optional[str]:
try:
async with utils.request.http_session.post(
'https://fanyi.qq.com/api/translate',
@@ -345,7 +430,7 @@ class TencentTranslateFree(FlowControlTranslateProvider):
return None
self._update_uc_key(r)
data = await r.json()
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
except (aiohttp.ClientError, asyncio.TimeoutError):
return None
if data['errCode'] != 0:
logger.warning('TencentTranslateFree failed: %d %s', data['errCode'], data['errMsg'])
@@ -353,7 +438,7 @@ class TencentTranslateFree(FlowControlTranslateProvider):
res = ''.join(record['targetText'] for record in data['translate']['records'])
if res == '' and text.strip() != '':
# qtv、qtk过期
logger.warning('TencentTranslateFree result is empty %s', data)
logger.info('TencentTranslateFree result is empty %s', data)
return None
return res
@@ -402,8 +487,8 @@ class TencentTranslateFree(FlowControlTranslateProvider):
def _on_fail(self):
self._fail_count += 1
# 为了可靠性,连续失败10次时冷却直到下次重新init
if self._fail_count >= 10:
# 为了可靠性,连续失败5次时冷却直到下次重新init
if self._fail_count >= 5:
self._cool_down()
def _cool_down(self):
@@ -413,11 +498,13 @@ class TencentTranslateFree(FlowControlTranslateProvider):
self._qtv = self._qtk = ''
self._fail_count = 0
self._on_availability_change()
class TencentTranslate(FlowControlTranslateProvider):
def __init__(self, query_interval, max_queue_size, source_language, target_language,
class TencentTranslate(TranslateProvider):
def __init__(self, query_interval, source_language, target_language,
secret_id, secret_key, region):
super().__init__(query_interval, max_queue_size)
super().__init__(query_interval)
self._source_language = source_language
self._target_language = target_language
self._secret_id = secret_id
@@ -430,7 +517,7 @@ class TencentTranslate(FlowControlTranslateProvider):
def is_available(self):
return self._cool_down_timer_handle is None and super().is_available
async def _do_translate(self, text):
async def _do_translate(self, text) -> Optional[str]:
try:
async with self._request_tencent_cloud(
'TextTranslate',
@@ -515,18 +602,20 @@ class TencentTranslate(FlowControlTranslateProvider):
# 需要手动处理等5分钟
sleep_time = 5 * 60
if sleep_time != 0:
self._cool_down_timer_handle = asyncio.get_event_loop().call_later(
self._cool_down_timer_handle = asyncio.get_running_loop().call_later(
sleep_time, self._on_cool_down_timeout
)
self._on_availability_change()
def _on_cool_down_timeout(self):
self._cool_down_timer_handle = None
self._on_availability_change()
class BaiduTranslate(FlowControlTranslateProvider):
def __init__(self, query_interval, max_queue_size, source_language, target_language,
class BaiduTranslate(TranslateProvider):
def __init__(self, query_interval, source_language, target_language,
app_id, secret):
super().__init__(query_interval, max_queue_size)
super().__init__(query_interval)
self._source_language = source_language
self._target_language = target_language
self._app_id = app_id
@@ -538,7 +627,7 @@ class BaiduTranslate(FlowControlTranslateProvider):
def is_available(self):
return self._cool_down_timer_handle is None and super().is_available
async def _do_translate(self, text):
async def _do_translate(self, text) -> Optional[str]:
try:
async with utils.request.http_session.post(
'https://fanyi-api.baidu.com/api/trans/vip/translate',
@@ -577,9 +666,11 @@ class BaiduTranslate(FlowControlTranslateProvider):
# 账户余额不足需要手动处理等5分钟
sleep_time = 5 * 60
if sleep_time != 0:
self._cool_down_timer_handle = asyncio.get_event_loop().call_later(
self._cool_down_timer_handle = asyncio.get_running_loop().call_later(
sleep_time, self._on_cool_down_timeout
)
self._on_availability_change()
def _on_cool_down_timeout(self):
self._cool_down_timer_handle = None
self._on_availability_change()

View File

@@ -5,11 +5,11 @@ import aiohttp
import utils.request
VERSION = 'v1.6.2'
VERSION = 'v1.8.2'
def check_update():
asyncio.ensure_future(_do_check_update())
asyncio.get_running_loop().create_task(_do_check_update())
async def _do_check_update():

View File

@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
from typing import *
import aiohttp
# 不带这堆头部有时候也能成功请求,但是带上后成功的概率更高
@@ -9,4 +11,14 @@ BILIBILI_COMMON_HEADERS = {
' Chrome/114.0.0.0 Safari/537.36'
}
http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
http_session: Optional[aiohttp.ClientSession] = None
def init():
global http_session
http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
async def shut_down():
if http_session is not None:
await http_session.close()