Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5c7554365 | ||
|
|
c6272c908a | ||
|
|
926ee879a6 | ||
|
|
a35d7a5bfc | ||
|
|
72c11d60da | ||
|
|
a50461d4e6 | ||
|
|
78828963ce | ||
|
|
68f8ab3a92 | ||
|
|
cad573312b | ||
|
|
3fb1c41845 | ||
|
|
6d07110927 | ||
|
|
de0bac3119 | ||
|
|
07569033fa | ||
|
|
72841ab46e | ||
|
|
fe141bc8fd | ||
|
|
f5672c3e9c | ||
|
|
b57bac3765 | ||
|
|
7c2c48273c | ||
|
|
5bdf2e41df | ||
|
|
3d9095536d | ||
|
|
c3db0a41b3 | ||
|
|
60b9f81fe2 | ||
|
|
00c47a3e55 | ||
|
|
93e374ac12 | ||
|
|
dac531920e | ||
|
|
c3566823c1 | ||
|
|
17b4985632 | ||
|
|
4314bb61e9 | ||
|
|
bea8711353 | ||
|
|
8fa63f1d75 | ||
|
|
8e7d9b266b | ||
|
|
aea6f12bd2 | ||
|
|
ecb4a08e56 | ||
|
|
6d5232d7d8 | ||
|
|
63f541b87e | ||
|
|
e478959a1b | ||
|
|
57ec8495e1 | ||
|
|
d5e8054e12 | ||
|
|
13b3e54f2b | ||
|
|
f8520909f4 | ||
|
|
92cead6ff1 | ||
|
|
b483c6beab | ||
|
|
f748d35fe4 | ||
|
|
b98a8f6680 | ||
|
|
914a41700e | ||
|
|
085765b1a2 | ||
|
|
14c2fd48df | ||
|
|
8683c0bad7 | ||
|
|
a1189aea69 | ||
|
|
9ce1dd21aa | ||
|
|
1f89d0d1cf | ||
|
|
062e7ed1aa | ||
|
|
6c84cbb930 | ||
|
|
582508bd3f | ||
|
|
2c0dd6e1c9 | ||
|
|
1075b458aa | ||
|
|
aac9f62a70 | ||
|
|
86847c666f |
@@ -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 . ./
|
||||
|
||||
57
README.md
@@ -1,11 +1,15 @@
|
||||
# blivechat
|
||||
|
||||
用于OBS的仿YouTube风格的bilibili直播评论栏
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 特性
|
||||
|
||||
* 兼容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.6以上版本):
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
136
api/chat.py
@@ -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
|
||||
@@ -96,6 +96,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 +117,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,38 +126,38 @@ 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
|
||||
)
|
||||
|
||||
def _on_receive_timeout(self):
|
||||
logger.warning('client=%s timed out', self.request.remote_ip)
|
||||
logger.info('client=%s timed out', self.request.remote_ip)
|
||||
self._receive_timeout_timer_handle = None
|
||||
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 +167,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 +184,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 +227,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 +245,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,
|
||||
@@ -265,6 +292,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."
|
||||
@@ -276,7 +306,7 @@ class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
|
||||
sc_data['price'] = 100
|
||||
sc_data['content'] = '敏捷的棕色狐狸跳过了懒狗'
|
||||
self.send_cmd_data(Command.ADD_SUPER_CHAT, sc_data)
|
||||
# self.send_message(Command.DEL_SUPER_CHAT, {'ids': [sc_data['id']]})
|
||||
# self.send_cmd_data(Command.DEL_SUPER_CHAT, {'ids': [sc_data['id']]})
|
||||
self.send_cmd_data(Command.ADD_GIFT, gift_data)
|
||||
gift_data['id'] = uuid.uuid4().hex
|
||||
gift_data['totalCoin'] = 1245000
|
||||
@@ -284,12 +314,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 +337,15 @@ 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, params={'room_id': room_id}
|
||||
dm_web_cli.ROOM_INIT_URL,
|
||||
headers={
|
||||
**utils.request.BILIBILI_COMMON_HEADERS,
|
||||
'Origin': 'https://live.bilibili.com',
|
||||
'Referer': f'https://live.bilibili.com/{room_id}'
|
||||
},
|
||||
params={
|
||||
'room_id': room_id
|
||||
}
|
||||
) as res:
|
||||
if res.status != 200:
|
||||
logger.warning('room=%d _get_room_info failed: %d %s', room_id,
|
||||
@@ -324,13 +363,8 @@ 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'))
|
||||
avatar_url = await services.avatar.get_avatar_url_or_none(uid)
|
||||
@@ -341,6 +375,20 @@ class AvatarHandler(api.base.ApiHandler): # noqa
|
||||
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),
|
||||
]
|
||||
|
||||
34
api/main.py
@@ -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}),
|
||||
]
|
||||
|
||||
194
api/open_live.py
Normal file
@@ -0,0 +1,194 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import asyncio
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
from typing import *
|
||||
|
||||
import aiohttp
|
||||
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'
|
||||
|
||||
|
||||
class TransportError(Exception):
|
||||
"""网络错误或HTTP状态码错误"""
|
||||
|
||||
|
||||
class BusinessError(Exception):
|
||||
"""业务返回码错误"""
|
||||
def __init__(self, data: dict):
|
||||
super().__init__(f"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
|
||||
|
||||
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)
|
||||
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}')
|
||||
|
||||
|
||||
class _OpenLiveHandlerBase(api.base.ApiHandler):
|
||||
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:
|
||||
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:
|
||||
res = e.data
|
||||
self.write(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:
|
||||
res = await _request_open_live(self._OPEN_LIVE_URL, self.json_args)
|
||||
except TransportError:
|
||||
raise tornado.web.HTTPError(500)
|
||||
except BusinessError as e:
|
||||
res = e.data
|
||||
self.write(res)
|
||||
|
||||
|
||||
class StartGamePublicHandler(_PublicHandlerBase):
|
||||
_OPEN_LIVE_URL = START_GAME_OPEN_LIVE_URL
|
||||
_COMMON_SERVER_URL = START_GAME_COMMON_SERVER_URL
|
||||
|
||||
|
||||
class StartGamePrivateHandler(_PrivateHandlerBase):
|
||||
_OPEN_LIVE_URL = START_GAME_OPEN_LIVE_URL
|
||||
|
||||
|
||||
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),
|
||||
]
|
||||
2
blivedm
55
config.py
@@ -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()
|
||||
|
||||
@@ -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,20 +61,25 @@ 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、容灾
|
||||
# 不同配置可以使用同一个类型,但要使用不同的账号,否则还是会遇到额度、调用频率限制
|
||||
translator_configs = tencent_translate_free,bilibili_translate_free
|
||||
translator_configs = tencent_translate_free
|
||||
|
||||
|
||||
[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
|
||||
@@ -75,16 +89,6 @@ source_language = zh
|
||||
target_language = jp
|
||||
|
||||
|
||||
[bilibili_translate_free]
|
||||
# 类型:B站翻译白嫖版。使用了B站直播网页的接口,**将来可能失效**。目前B站翻译后端是百度翻译
|
||||
type = BilibiliTranslateFree
|
||||
|
||||
# 请求间隔时间(秒),等于 1 / QPS。目前此接口频率限制是3秒一次
|
||||
query_interval = 3.1
|
||||
# 最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间
|
||||
max_queue_size = 3
|
||||
|
||||
|
||||
[tencent_translate]
|
||||
# 文档:https://cloud.tencent.com/product/tmt
|
||||
# 定价:https://cloud.tencent.com/document/product/551/35017
|
||||
@@ -99,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
|
||||
@@ -122,17 +124,15 @@ region = ap-shanghai
|
||||
[baidu_translate]
|
||||
# 文档:https://fanyi-api.baidu.com/
|
||||
# 定价:https://fanyi-api.baidu.com/product/112
|
||||
# * 标准版完全免费,不限使用字符量(QPS=1)
|
||||
# * 高级版每月前200万字符免费,超出后仅收取超出部分费用(QPS=10),49元/百万字符
|
||||
# * 尊享版每月前200万字符免费,超出后仅收取超出部分费用(QPS=100),49元/百万字符
|
||||
# * 标准版每月前5万字符免费,超出仅收取超出部分费用(QPS=1),按49元/百万字符计费
|
||||
# * 高级版每月前100万字符免费,超出仅收取超出部分费用(QPS=10),按49元/百万字符计费
|
||||
# * 尊享版每月前200万字符免费,超出后仅收取超出部分费用(QPS=100),按49元/百万字符计费
|
||||
|
||||
# 类型:百度翻译
|
||||
type = BaiduTranslate
|
||||
|
||||
# 请求间隔时间(秒),等于 1 / QPS
|
||||
query_interval = 1.5
|
||||
# 最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间
|
||||
max_queue_size = 9
|
||||
|
||||
# 自动:auto;中文:zh;日语:jp;英语:en;韩语:kor
|
||||
# 完整语言列表见文档:https://fanyi-api.baidu.com/doc/21
|
||||
@@ -144,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
|
||||
|
||||
@@ -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个单词
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"vueCompilerOptions": {
|
||||
"target": 2.7,
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*.js",
|
||||
"./src/**/*.vue"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "blivechat",
|
||||
"version": "1.6.0",
|
||||
"version": "1.8.0",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 86 KiB |
255
frontend/src/api/chat/ChatClientDirectOpenLive.js
Normal file
@@ -0,0 +1,255 @@
|
||||
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) {
|
||||
throw Error(`code=${res.code}, message=${res.message}, request_id=${res.request_id}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('startGame failed:', 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: false,
|
||||
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,
|
||||
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
|
||||
}
|
||||
185
frontend/src/api/chat/ChatClientDirectWeb.js
Normal file
@@ -0,0 +1,185 @@
|
||||
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 data = {
|
||||
avatarUrl: await chat.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: 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),
|
||||
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
|
||||
}
|
||||
@@ -1,40 +1,37 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { BrotliDecode } from './brotli_decode'
|
||||
import { getUuid4Hex } from '@/utils'
|
||||
import * as avatar from '../avatar'
|
||||
import { inflate } from 'pako'
|
||||
|
||||
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 +39,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 +50,7 @@ export default class ChatClientDirect {
|
||||
this.onDelSuperChat = null
|
||||
this.onUpdateTranslation = null
|
||||
|
||||
this.needInitRoom = true
|
||||
this.websocket = null
|
||||
this.retryCount = 0
|
||||
this.isDestroying = false
|
||||
@@ -65,8 +58,7 @@ export default class ChatClientDirect {
|
||||
this.receiveTimeoutTimerId = null
|
||||
}
|
||||
|
||||
async start() {
|
||||
await this.initRoom()
|
||||
start() {
|
||||
this.wsConnect()
|
||||
}
|
||||
|
||||
@@ -78,23 +70,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 +91,42 @@ export default class ChatClientDirect {
|
||||
}
|
||||
|
||||
sendAuth() {
|
||||
let authParams = {
|
||||
uid: 0,
|
||||
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
|
||||
}
|
||||
|
||||
if (!await this.initRoom()) {
|
||||
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)
|
||||
@@ -183,7 +181,6 @@ export default class ChatClientDirect {
|
||||
}
|
||||
|
||||
onWsMessage(event) {
|
||||
this.refreshReceiveTimeoutTimer()
|
||||
if (!(event.data instanceof ArrayBuffer)) {
|
||||
console.warn('未知的websocket消息类型,data=', event.data)
|
||||
return
|
||||
@@ -226,6 +223,7 @@ export default class ChatClientDirect {
|
||||
}
|
||||
case OP_HEARTBEAT_REPLY: {
|
||||
// 服务器心跳包,包含人气值,这里没用
|
||||
this.refreshReceiveTimeoutTimer()
|
||||
break
|
||||
}
|
||||
default: {
|
||||
@@ -248,6 +246,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 +269,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 +290,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
|
||||
}
|
||||
@@ -10,12 +10,11 @@ const COMMAND_UPDATE_TRANSLATION = 7
|
||||
// const CONTENT_TYPE_TEXT = 0
|
||||
const CONTENT_TYPE_EMOTICON = 1
|
||||
|
||||
const HEARTBEAT_INTERVAL = 10 * 1000
|
||||
const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + (5 * 1000)
|
||||
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
|
||||
@@ -28,7 +27,6 @@ export default class ChatClientRelay {
|
||||
this.websocket = null
|
||||
this.retryCount = 0
|
||||
this.isDestroying = false
|
||||
this.heartbeatTimerId = null
|
||||
this.receiveTimeoutTimerId = null
|
||||
}
|
||||
|
||||
@@ -60,22 +58,15 @@ export default class ChatClientRelay {
|
||||
this.websocket.send(JSON.stringify({
|
||||
cmd: COMMAND_JOIN_ROOM,
|
||||
data: {
|
||||
roomId: this.roomId,
|
||||
roomKey: this.roomKey,
|
||||
config: {
|
||||
autoTranslate: this.autoTranslate
|
||||
}
|
||||
}
|
||||
}))
|
||||
this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), HEARTBEAT_INTERVAL)
|
||||
this.refreshReceiveTimeoutTimer()
|
||||
}
|
||||
|
||||
sendHeartbeat() {
|
||||
this.websocket.send(JSON.stringify({
|
||||
cmd: COMMAND_HEARTBEAT
|
||||
}))
|
||||
}
|
||||
|
||||
refreshReceiveTimeoutTimer() {
|
||||
if (this.receiveTimeoutTimerId) {
|
||||
window.clearTimeout(this.receiveTimeoutTimerId)
|
||||
@@ -95,10 +86,6 @@ export default class ChatClientRelay {
|
||||
|
||||
onWsClose() {
|
||||
this.websocket = null
|
||||
if (this.heartbeatTimerId) {
|
||||
window.clearInterval(this.heartbeatTimerId)
|
||||
this.heartbeatTimerId = null
|
||||
}
|
||||
if (this.receiveTimeoutTimerId) {
|
||||
window.clearTimeout(this.receiveTimeoutTimerId)
|
||||
this.receiveTimeoutTimerId = null
|
||||
@@ -112,11 +99,15 @@ export default class ChatClientRelay {
|
||||
}
|
||||
|
||||
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
|
||||
}))
|
||||
break
|
||||
}
|
||||
case COMMAND_ADD_TEXT: {
|
||||
@@ -145,7 +136,7 @@ export default class ChatClientRelay {
|
||||
medalLevel: data[10],
|
||||
id: data[11],
|
||||
translation: data[12],
|
||||
emoticon: emoticon
|
||||
emoticon: emoticon,
|
||||
}
|
||||
this.onAddText(data)
|
||||
break
|
||||
|
||||
@@ -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
|
||||
@@ -219,7 +253,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() {
|
||||
|
||||
@@ -8,10 +8,6 @@ export function processAvatarUrl(avatarUrl) {
|
||||
if (m) {
|
||||
avatarUrl = m[1]
|
||||
}
|
||||
// 缩小图片加快传输
|
||||
if (!avatarUrl.endsWith('noface.gif')) {
|
||||
avatarUrl += '@48w_48h'
|
||||
}
|
||||
return avatarUrl
|
||||
}
|
||||
|
||||
@@ -26,3 +22,13 @@ export async function getAvatarUrl(uid) {
|
||||
}
|
||||
return res.avatarUrl
|
||||
}
|
||||
|
||||
export async function getTextEmoticons() {
|
||||
let res
|
||||
try {
|
||||
res = (await axios.get('/api/text_emoticon_mappings')).data
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
return res.textEmoticons
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 9.8 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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个需要平滑的消息
|
||||
|
||||
@@ -9,9 +9,13 @@ export default {
|
||||
home: {
|
||||
roomIdEmpty: "Room ID can't be empty",
|
||||
roomIdInteger: 'Room ID must be positive integer',
|
||||
authCodeEmpty: "Identity code can't be empty",
|
||||
useAuthCodeWarning: 'Please prioritize the identity code',
|
||||
|
||||
general: 'General',
|
||||
room: 'Room',
|
||||
roomId: 'Room ID',
|
||||
authCode: 'Identity code',
|
||||
showDanmaku: 'Show messages',
|
||||
showGift: 'Show Super Chats',
|
||||
showGiftName: 'Show gift name',
|
||||
@@ -130,8 +134,8 @@ 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. Copy the identity code (身份码) from the webpage where you start streaming',
|
||||
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'
|
||||
|
||||
@@ -9,9 +9,13 @@ export default {
|
||||
home: {
|
||||
roomIdEmpty: 'ルームのIDを空白にすることはできません',
|
||||
roomIdInteger: 'ルームは正の整数でなければなりません',
|
||||
authCodeEmpty: 'アイデンティティコードを空白にすることはできません',
|
||||
useAuthCodeWarning: 'アイデンティティコードを優先的に使用してください',
|
||||
|
||||
general: '常規',
|
||||
room: 'ルーム',
|
||||
roomId: 'ルームID',
|
||||
authCode: 'アイデンティティコード',
|
||||
showDanmaku: 'コメントを表示する',
|
||||
showGift: 'スーパーチャットと新メンバーを表示する',
|
||||
showGiftName: 'ギフト名を表示する',
|
||||
@@ -130,8 +134,8 @@ export default {
|
||||
},
|
||||
help: {
|
||||
help: 'ヘルプ',
|
||||
p1: '1. ビリビリの生放送ウェブから生放送ルームIDをこぴーする',
|
||||
p2: '2. ホームページでコピーしたIDを入力し、ルームのURLをこぴーする',
|
||||
p1: '1. ビリビリの生放送を始めるのウェブからアイデンティティコード(身份码)をこぴーする',
|
||||
p2: '2. ホームページに先ほどコピーしたアイデンティティコードを入力して、ルームのURLをこぴーする',
|
||||
p3: '3. スタイルジェネレータでお好みのコメント様子を選び、出力したCSSをコピーする',
|
||||
p4: '4. OBSでブラウザを新規作成する',
|
||||
p5: '5. プロパティでこぴーしたURLを入力し、カスタムCSSでスタイルジェネレータのCSSを入力する'
|
||||
|
||||
@@ -9,9 +9,13 @@ export default {
|
||||
home: {
|
||||
roomIdEmpty: '房间ID不能为空',
|
||||
roomIdInteger: '房间ID必须为正整数',
|
||||
authCodeEmpty: '身份码不能为空',
|
||||
useAuthCodeWarning: '请优先使用身份码',
|
||||
|
||||
general: '常规',
|
||||
room: '房间',
|
||||
roomId: '房间ID',
|
||||
authCode: '身份码',
|
||||
showDanmaku: '显示弹幕',
|
||||
showGift: '显示打赏和新舰长',
|
||||
showGiftName: '显示礼物名',
|
||||
@@ -130,8 +134,8 @@ export default {
|
||||
},
|
||||
help: {
|
||||
help: '帮助',
|
||||
p1: '1. 从B站直播间网页复制房间ID',
|
||||
p2: '2. 把房间ID输入到首页的房间ID,复制房间URL',
|
||||
p1: '1. 从开始直播的页面复制身份码',
|
||||
p2: '2. 把身份码输入到首页的房间配置,复制房间URL',
|
||||
p3: '3. 使用样式生成器生成样式,复制CSS',
|
||||
p4: '4. 在OBS中添加浏览器源',
|
||||
p5: '5. URL处输入之前复制的房间URL,自定义CSS处输入之前复制的CSS'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -65,7 +65,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 +77,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 }
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
<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>
|
||||
<el-form-item :label="$t('home.room')" required :prop="form.roomKeyType === 1 ? 'roomId' : 'authCode'">
|
||||
<template slot="label">{{ $t('home.room') }}
|
||||
<router-link :to="{ name: 'help' }">
|
||||
<i class="el-icon-question"></i>
|
||||
</router-link>
|
||||
</template>
|
||||
<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-if="form.roomKeyType === 1"
|
||||
v-model.number="form.roomId" type="number" min="1" :rules="[
|
||||
{required: true, message: $t('home.roomIdEmpty'), trigger: 'blur'},
|
||||
{type: 'integer', min: 1, message: $t('home.roomIdInteger'), trigger: 'blur'}
|
||||
]"
|
||||
></el-input>
|
||||
<el-input v-else
|
||||
v-model.number="form.authCode" :rules="[
|
||||
{required: true, message: $t('home.authCodeEmpty'), trigger: 'blur'}
|
||||
]"
|
||||
></el-input>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row v-if="form.roomKeyType === 1" style="color: red">{{ $t('home.useAuthCodeWarning') }}</el-row>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="8">
|
||||
@@ -184,11 +205,20 @@ 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 || '',
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
roomKeyValue() {
|
||||
if (this.form.roomKeyType === 1) {
|
||||
return this.form.roomId
|
||||
} else {
|
||||
return this.form.authCode
|
||||
}
|
||||
},
|
||||
roomUrl() {
|
||||
return this.getRoomUrl(false)
|
||||
},
|
||||
@@ -206,7 +236,9 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
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)
|
||||
},
|
||||
@@ -256,13 +288,13 @@ 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')
|
||||
},
|
||||
getRoomUrl(isTestRoom) {
|
||||
if (!isTestRoom && this.form.roomId === '') {
|
||||
if (!isTestRoom && !this.roomKeyValue) {
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -272,12 +304,13 @@ export default {
|
||||
lang: this.$i18n.locale
|
||||
}
|
||||
delete query.roomId
|
||||
delete query.authCode
|
||||
|
||||
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}`
|
||||
},
|
||||
@@ -314,7 +347,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +66,11 @@ 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
|
||||
@@ -70,6 +79,7 @@ export default {
|
||||
mounted() {
|
||||
this.initConfig()
|
||||
this.initChatClient()
|
||||
this.initTextEmoticons()
|
||||
if (this.config.giftUsernamePronunciation !== '') {
|
||||
this.pronunciationConverter = new pronunciation.PronunciationConverter()
|
||||
this.pronunciationConverter.loadDict(this.config.giftUsernamePronunciation)
|
||||
@@ -134,13 +144,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
|
||||
@@ -151,6 +167,9 @@ export default {
|
||||
this.chatClient.onUpdateTranslation = this.onUpdateTranslation
|
||||
this.chatClient.start()
|
||||
},
|
||||
async initTextEmoticons() {
|
||||
this.textEmoticons = await chat.getTextEmoticons()
|
||||
},
|
||||
|
||||
start() {
|
||||
this.chatClient.start()
|
||||
@@ -272,7 +291,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 +331,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 +340,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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const API_BASE_URL = 'http://localhost:12450'
|
||||
// 不能用localhost,https://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
@@ -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()))
|
||||
|
||||
@@ -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.Integer, primary_key=True)
|
||||
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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
tornado==6.1.0
|
||||
sqlalchemy==2.0.19
|
||||
tornado==6.3.2
|
||||
|
||||
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 97 KiB |
@@ -1,12 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
from typing import *
|
||||
|
||||
import aiohttp
|
||||
import sqlalchemy
|
||||
import cachetools
|
||||
import sqlalchemy.exc
|
||||
|
||||
import config
|
||||
@@ -19,151 +20,93 @@ 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
|
||||
# 正在获取头像的Future,user_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
|
||||
|
||||
|
||||
@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) -> str:
|
||||
avatar_url = await get_avatar_url_or_none(user_id)
|
||||
if avatar_url is None:
|
||||
avatar_url = DEFAULT_AVATAR_URL
|
||||
return avatar_url
|
||||
|
||||
|
||||
async def get_avatar_url_or_none(user_id):
|
||||
avatar_url = get_avatar_url_from_memory(user_id)
|
||||
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)
|
||||
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):
|
||||
try:
|
||||
async with utils.request.http_session.get(
|
||||
'https://api.bilibili.com/x/space/acc/info', params={'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
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 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):
|
||||
@@ -171,31 +114,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(
|
||||
@@ -206,7 +172,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'])
|
||||
|
||||
586
services/chat.py
@@ -1,11 +1,15 @@
|
||||
# -*- 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 config
|
||||
import services.avatar
|
||||
import services.translate
|
||||
@@ -13,6 +17,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 +55,177 @@ 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):
|
||||
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,
|
||||
)
|
||||
|
||||
@property
|
||||
def tmp_room_id(self):
|
||||
"""初始化参数传入的房间ID,room_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,
|
||||
)
|
||||
|
||||
@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:
|
||||
logger.warning('_start_game() failed')
|
||||
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 +235,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 +324,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 +341,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 +365,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 = 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 +401,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 +434,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 +458,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)
|
||||
|
||||
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 +478,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,34 +509,36 @@ 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
|
||||
|
||||
room.send_cmd_data(api.chat.Command.ADD_SUPER_CHAT, {
|
||||
room.send_cmd_data(api.chat.Command.DEL_SUPER_CHAT, {
|
||||
'ids': list(map(str, message.ids))
|
||||
})
|
||||
|
||||
@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 +550,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,
|
||||
'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))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
# 正在翻译的Future,text -> 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,21 +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_ == 'BilibiliTranslateFree':
|
||||
return BilibiliTranslateFree(cfg['query_interval'], cfg['max_queue_size'])
|
||||
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
|
||||
|
||||
@@ -95,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)
|
||||
@@ -110,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
|
||||
|
||||
|
||||
@@ -142,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
|
||||
|
||||
@@ -226,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):
|
||||
@@ -246,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
|
||||
|
||||
@@ -281,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
|
||||
|
||||
@@ -298,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',
|
||||
@@ -347,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'])
|
||||
@@ -355,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
|
||||
|
||||
@@ -404,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):
|
||||
@@ -415,39 +498,13 @@ class TencentTranslateFree(FlowControlTranslateProvider):
|
||||
self._qtv = self._qtk = ''
|
||||
self._fail_count = 0
|
||||
|
||||
|
||||
class BilibiliTranslateFree(FlowControlTranslateProvider):
|
||||
def __init__(self, query_interval, max_queue_size):
|
||||
super().__init__(query_interval, max_queue_size)
|
||||
|
||||
async def _do_translate(self, text):
|
||||
try:
|
||||
async with utils.request.http_session.get(
|
||||
'https://api.live.bilibili.com/av/v1/SuperChat/messageTranslate',
|
||||
params={
|
||||
'room_id': '21396545',
|
||||
'ruid': '407106379',
|
||||
'parent_area_id': '9',
|
||||
'area_id': '371',
|
||||
'msg': text
|
||||
}
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
logger.warning('BilibiliTranslateFree request failed: status=%d %s', r.status, r.reason)
|
||||
return None
|
||||
data = await r.json()
|
||||
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
||||
return None
|
||||
if data['code'] != 0:
|
||||
logger.warning('BilibiliTranslateFree failed: %d %s', data['code'], data['msg'])
|
||||
return None
|
||||
return data['data']['message_trans']
|
||||
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
|
||||
@@ -460,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',
|
||||
@@ -545,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
|
||||
@@ -568,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',
|
||||
@@ -607,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()
|
||||
|
||||
@@ -5,11 +5,11 @@ import aiohttp
|
||||
|
||||
import utils.request
|
||||
|
||||
VERSION = 'v1.6.0'
|
||||
VERSION = 'v1.8.0'
|
||||
|
||||
|
||||
def check_update():
|
||||
asyncio.ensure_future(_do_check_update())
|
||||
asyncio.get_running_loop().create_task(_do_check_update())
|
||||
|
||||
|
||||
async def _do_check_update():
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from typing import *
|
||||
|
||||
import aiohttp
|
||||
|
||||
http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
|
||||
# 不带这堆头部有时候也能成功请求,但是带上后成功的概率更高
|
||||
BILIBILI_COMMON_HEADERS = {
|
||||
'Origin': 'https://www.bilibili.com',
|
||||
'Referer': 'https://www.bilibili.com/',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)'
|
||||
' Chrome/114.0.0.0 Safari/537.36'
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||