Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2d12e84bd | ||
|
|
618164d77d | ||
|
|
9f8f238426 | ||
|
|
801280efbd | ||
|
|
2187dfc167 | ||
|
|
02ec4bc4ba | ||
|
|
24d0671dae | ||
|
|
903cfecab9 | ||
|
|
3da8cc4227 | ||
|
|
4924f15614 | ||
|
|
3a183a865d | ||
|
|
4c8cde0b3d | ||
|
|
a68b50e885 | ||
|
|
b5b9b2601a | ||
|
|
6c92859408 | ||
|
|
c87325e5e0 | ||
|
|
e40f1511ed | ||
|
|
3b556fc265 | ||
|
|
c05c70863a | ||
|
|
781de48b4b | ||
|
|
ccf9ceec0e | ||
|
|
707ad57800 | ||
|
|
8eff63e17b | ||
|
|
36345c37ab | ||
|
|
03a2801099 | ||
|
|
e318bdfcac | ||
|
|
23ebaca372 | ||
|
|
6da20d9bdd | ||
|
|
dedae6f083 | ||
|
|
3118a7de98 | ||
|
|
34f8d88e05 | ||
|
|
776b354517 | ||
|
|
2a22617c1a | ||
|
|
6b77a8a17f | ||
|
|
8780655341 | ||
|
|
6a4faa83a4 |
@@ -18,4 +18,7 @@ README.md
|
||||
# runtime data
|
||||
data/*
|
||||
!data/config.example.ini
|
||||
!data/emoticons/
|
||||
data/emoticons/*
|
||||
!data/emoticons/.gitkeep
|
||||
log/*
|
||||
|
||||
3
.gitignore
vendored
@@ -107,4 +107,7 @@ venv.bak/
|
||||
.idea/
|
||||
data/*
|
||||
!data/config.example.ini
|
||||
!data/emoticons/
|
||||
data/emoticons/*
|
||||
!data/emoticons/.gitkeep
|
||||
log/*
|
||||
|
||||
59
Dockerfile
@@ -1,35 +1,46 @@
|
||||
# 运行时
|
||||
FROM python:3.7.10-slim-stretch
|
||||
RUN mv /etc/apt/sources.list /etc/apt/sources.list.bak \
|
||||
&& echo "deb http://mirrors.tuna.tsinghua.edu.cn/debian/ stretch main contrib non-free">>/etc/apt/sources.list \
|
||||
&& echo "deb http://mirrors.tuna.tsinghua.edu.cn/debian/ stretch-updates main contrib non-free">>/etc/apt/sources.list \
|
||||
&& echo "deb http://mirrors.tuna.tsinghua.edu.cn/debian/ stretch-backports main contrib non-free">>/etc/apt/sources.list \
|
||||
&& echo "deb http://mirrors.tuna.tsinghua.edu.cn/debian-security stretch/updates main contrib non-free">>/etc/apt/sources.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y wget tar xz-utils
|
||||
RUN wget https://nodejs.org/dist/v10.16.0/node-v10.16.0-linux-x64.tar.xz \
|
||||
&& tar -xvf node-v10.16.0-linux-x64.tar.xz \
|
||||
&& rm node-v10.16.0-linux-x64.tar.xz \
|
||||
&& ln -s /node-v10.16.0-linux-x64/bin/node /usr/local/bin/node \
|
||||
&& ln -s /node-v10.16.0-linux-x64/bin/npm /usr/local/bin/npm
|
||||
#
|
||||
# 构建前端
|
||||
#
|
||||
|
||||
FROM node:16.14.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
|
||||
|
||||
# 编译前端
|
||||
COPY frontend ./
|
||||
RUN npm run build
|
||||
|
||||
#
|
||||
# 准备后端
|
||||
#
|
||||
|
||||
FROM python:3.8.12-bullseye
|
||||
ARG BASE_PATH='/root/blivechat'
|
||||
ARG EXT_DATA_PATH='/mnt/data'
|
||||
WORKDIR "${BASE_PATH}"
|
||||
|
||||
# 后端依赖
|
||||
WORKDIR /blivechat
|
||||
COPY requirements.txt ./
|
||||
RUN pip3 install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||
|
||||
# 前端依赖
|
||||
WORKDIR ./frontend
|
||||
COPY frontend/package.json ./
|
||||
RUN npm i --registry=https://registry.npm.taobao.org
|
||||
# 数据目录
|
||||
COPY . ./
|
||||
RUN mkdir -p "${EXT_DATA_PATH}/frontend/dist" \
|
||||
&& mv data "${EXT_DATA_PATH}/data" \
|
||||
&& ln -s "${EXT_DATA_PATH}/data" data \
|
||||
&& mv log "${EXT_DATA_PATH}/log" \
|
||||
&& ln -s "${EXT_DATA_PATH}/log" log \
|
||||
&& ln -s "${EXT_DATA_PATH}/frontend/dist" frontend/dist
|
||||
|
||||
# 编译前端
|
||||
COPY . ../
|
||||
RUN npm run build
|
||||
# 编译好的前端
|
||||
COPY --from=builder "${BASE_PATH}/frontend/dist" "${EXT_DATA_PATH}/frontend/dist/"
|
||||
|
||||
# 运行
|
||||
WORKDIR ..
|
||||
VOLUME /blivechat/data /blivechat/log /blivechat/frontend/dist
|
||||
VOLUME "${EXT_DATA_PATH}"
|
||||
EXPOSE 12450
|
||||
ENTRYPOINT ["python3", "main.py"]
|
||||
CMD ["--host", "0.0.0.0", "--port", "12450"]
|
||||
|
||||
88
README.md
@@ -1,28 +1,30 @@
|
||||
# blivechat
|
||||
用于OBS的仿YouTube风格的bilibili直播评论栏
|
||||
|
||||
最近喜欢看VTuber,想为此写些程序,于是有了这个东西。~~写到一半发现有类似项目了:[bilibili-live-chat](https://github.com/Tsuk1ko/bilibili-live-chat)、[BiliChat](https://github.com/3Shain/BiliChat)~~
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 特性
|
||||
* 兼容YouTube直播评论栏的样式
|
||||
* 金瓜子礼物模仿醒目留言显示
|
||||
* 付费礼物模仿醒目留言显示
|
||||
* 高亮舰队、房管、主播的用户名
|
||||
* 支持屏蔽弹幕、合并相似弹幕等设置
|
||||
* 自带样式生成器
|
||||
* 支持自动翻译弹幕、醒目留言到日语
|
||||
* 支持标注打赏用户名的读音(拼音和日文假名)
|
||||
* 自带两种样式生成器,经典YouTube风格和仿微信风格
|
||||
* 支持前端直连B站服务器或者通过后端转发
|
||||
* 支持自动翻译弹幕、醒目留言到日语,可以在后台配置翻译目标语言
|
||||
* 支持标注打赏用户名的读音,可选拼音或日文假名
|
||||
* 支持配置自定义表情,不需要开通B站官方表情
|
||||
|
||||
## 使用方法
|
||||
以下几种方式任选一种即可
|
||||
|
||||
### 一、本地使用
|
||||
1. 下载[发布版](https://github.com/xfgryujk/blivechat/releases)(仅提供x64 Windows版)
|
||||
2. 双击`blivechat.exe`运行服务器,或者用命令行可以指定host和端口号:
|
||||
```bat
|
||||
blivechat.exe --host 127.0.0.1 --port 12450
|
||||
```
|
||||
```sh
|
||||
blivechat.exe --host 127.0.0.1 --port 12450
|
||||
```
|
||||
3. 用浏览器打开[http://localhost:12450](http://localhost:12450),输入房间ID,复制房间URL
|
||||
4. 用样式生成器生成样式,复制CSS
|
||||
5. 在OBS中添加浏览器源,输入URL和自定义CSS
|
||||
@@ -31,47 +33,45 @@
|
||||
|
||||
* 本地使用时不要关闭blivechat.exe那个黑框,否则不能继续获取头像或弹幕
|
||||
* 样式生成器没有列出所有本地字体,但是可以手动输入本地字体
|
||||
* 如果需要使用翻译功能,建议看[配置官方翻译接口傻瓜式教程](https://www.bilibili.com/read/cv14663633)
|
||||
|
||||
### 二、公共服务器
|
||||
请优先在本地使用,使用公共服务器会有更大的延迟,而且服务器故障时可能发生直播事故
|
||||
|
||||
* [公共服务器](http://chat.bilisc.com/)
|
||||
* [仅样式生成器](https://style.vtbs.moe/)
|
||||
|
||||
### 三、源代码版(自建服务器或在Windows以外平台)
|
||||
0. 由于使用了git子模块,clone时需要加上`--recursive`参数:
|
||||
```sh
|
||||
git clone --recursive https://github.com/xfgryujk/blivechat.git
|
||||
```
|
||||
如果已经clone,拉子模块的方法:
|
||||
```sh
|
||||
git submodule update --init --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
|
||||
```
|
||||
```sh
|
||||
cd frontend
|
||||
npm i
|
||||
npm run build
|
||||
```
|
||||
2. 运行服务器(需要Python3.6以上版本):
|
||||
```sh
|
||||
pip3 install -r requirements.txt
|
||||
python3 main.py
|
||||
```
|
||||
或者可以指定host和端口号:
|
||||
```sh
|
||||
python3 main.py --host 127.0.0.1 --port 12450
|
||||
```
|
||||
```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=blc-data,target=/blivechat/data \
|
||||
--mount source=blc-log,target=/blivechat/log \
|
||||
--mount source=blc-frontend,target=/blivechat/frontend/dist \
|
||||
xfgryujk/blivechat:latest
|
||||
```
|
||||
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),以下略
|
||||
|
||||
## 自建服务器相关补充
|
||||
@@ -83,7 +83,7 @@
|
||||
### 参考nginx配置
|
||||
`sudo vim /etc/nginx/sites-enabled/blivechat.conf`
|
||||
|
||||
```conf
|
||||
```nginx
|
||||
upstream blivechat {
|
||||
keepalive 8;
|
||||
# blivechat地址
|
||||
@@ -108,6 +108,9 @@ server {
|
||||
ssl_certificate /PATH/TO/CERT.crt;
|
||||
ssl_certificate_key /PATH/TO/CERT_KEY.key;
|
||||
|
||||
client_body_buffer_size 256k;
|
||||
client_max_body_size 1.1m;
|
||||
|
||||
# 代理header
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -121,6 +124,9 @@ server {
|
||||
# 如果文件不存在,交给前端路由
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
location /emoticons {
|
||||
alias /PATH/TO/BLIVECHAT/data/emoticons;
|
||||
}
|
||||
# 动态API
|
||||
location /api {
|
||||
proxy_pass http://blivechat;
|
||||
|
||||
33
api/base.py
@@ -1,31 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import json
|
||||
|
||||
import tornado.web
|
||||
|
||||
|
||||
# noinspection PyAbstractClass
|
||||
class ApiHandler(tornado.web.RequestHandler):
|
||||
def set_default_headers(self):
|
||||
# 跨域测试用
|
||||
if not self.application.settings['debug']:
|
||||
return
|
||||
self.set_header('Access-Control-Allow-Origin', '*')
|
||||
self.set_header('Access-Control-Allow-Methods', 'OPTIONS, PUT, POST, GET, DELETE')
|
||||
if 'Access-Control-Request-Headers' in self.request.headers:
|
||||
self.set_header('Access-Control-Allow-Headers',
|
||||
self.request.headers['Access-Control-Request-Headers'])
|
||||
class ApiHandler(tornado.web.RequestHandler): # noqa
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.json_args = None
|
||||
|
||||
def prepare(self):
|
||||
if self.request.headers.get('Content-Type', '').startswith('application/json'):
|
||||
try:
|
||||
self.json_args = json.loads(self.request.body)
|
||||
except json.JSONDecodeError:
|
||||
self.json_args = None
|
||||
else:
|
||||
self.json_args = None
|
||||
|
||||
async def options(self, *_args, **_kwargs):
|
||||
# 跨域测试用
|
||||
self.set_status(204 if self.application.settings['debug'] else 405)
|
||||
if not self.request.headers.get('Content-Type', '').startswith('application/json'):
|
||||
return
|
||||
try:
|
||||
self.json_args = json.loads(self.request.body)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
502
api/chat.py
@@ -1,5 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import asyncio
|
||||
import enum
|
||||
import json
|
||||
@@ -13,10 +12,12 @@ import aiohttp
|
||||
import tornado.websocket
|
||||
|
||||
import api.base
|
||||
import blivedm.blivedm as blivedm
|
||||
import blivedm.blivedm.client as blivedm_client
|
||||
import config
|
||||
import models.avatar
|
||||
import models.translate
|
||||
import services.avatar
|
||||
import services.chat
|
||||
import services.translate
|
||||
import utils.request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,242 +33,43 @@ class Command(enum.IntEnum):
|
||||
UPDATE_TRANSLATION = 7
|
||||
|
||||
|
||||
_http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
|
||||
|
||||
room_manager: Optional['RoomManager'] = None
|
||||
class ContentType(enum.IntEnum):
|
||||
TEXT = 0
|
||||
EMOTICON = 1
|
||||
|
||||
|
||||
def init():
|
||||
global room_manager
|
||||
room_manager = RoomManager()
|
||||
def make_message_body(cmd, data):
|
||||
return json.dumps(
|
||||
{
|
||||
'cmd': cmd,
|
||||
'data': data
|
||||
}
|
||||
).encode('utf-8')
|
||||
|
||||
|
||||
class Room(blivedm.BLiveClient):
|
||||
HEARTBEAT_INTERVAL = 10
|
||||
|
||||
# 重新定义parse_XXX是为了减少对字段名的依赖,防止B站改字段名
|
||||
def __parse_danmaku(self, command):
|
||||
info = command['info']
|
||||
if info[3]:
|
||||
room_id = info[3][3]
|
||||
medal_level = info[3][0]
|
||||
else:
|
||||
room_id = medal_level = 0
|
||||
return self._on_receive_danmaku(blivedm.DanmakuMessage(
|
||||
None, None, None, info[0][4], None, None, info[0][9], None,
|
||||
info[1],
|
||||
info[2][0], info[2][1], info[2][2], None, None, info[2][5], info[2][6], None,
|
||||
medal_level, None, None, room_id, None, None,
|
||||
info[4][0], None, None,
|
||||
None, None,
|
||||
info[7]
|
||||
))
|
||||
|
||||
def __parse_gift(self, command):
|
||||
data = command['data']
|
||||
return self._on_receive_gift(blivedm.GiftMessage(
|
||||
data['giftName'], data['num'], data['uname'], data['face'], None,
|
||||
data['uid'], data['timestamp'], None, None,
|
||||
None, None, None, data['coin_type'], data['total_coin']
|
||||
))
|
||||
|
||||
def __parse_buy_guard(self, command):
|
||||
data = command['data']
|
||||
return self._on_buy_guard(blivedm.GuardBuyMessage(
|
||||
data['uid'], data['username'], data['guard_level'], None, None,
|
||||
None, None, data['start_time'], None
|
||||
))
|
||||
|
||||
def __parse_super_chat(self, command):
|
||||
data = command['data']
|
||||
return self._on_super_chat(blivedm.SuperChatMessage(
|
||||
data['price'], data['message'], None, data['start_time'],
|
||||
None, None, data['id'], None,
|
||||
None, data['uid'], data['user_info']['uname'],
|
||||
data['user_info']['face'], None,
|
||||
None, None,
|
||||
None, None, None,
|
||||
None
|
||||
))
|
||||
|
||||
_COMMAND_HANDLERS = {
|
||||
**blivedm.BLiveClient._COMMAND_HANDLERS,
|
||||
'DANMU_MSG': __parse_danmaku,
|
||||
'SEND_GIFT': __parse_gift,
|
||||
'GUARD_BUY': __parse_buy_guard,
|
||||
'SUPER_CHAT_MESSAGE': __parse_super_chat
|
||||
}
|
||||
|
||||
def __init__(self, room_id):
|
||||
super().__init__(room_id, session=_http_session, heartbeat_interval=self.HEARTBEAT_INTERVAL)
|
||||
self.clients: List['ChatHandler'] = []
|
||||
self.auto_translate_count = 0
|
||||
|
||||
async def init_room(self):
|
||||
await super().init_room()
|
||||
return True
|
||||
|
||||
def stop_and_close(self):
|
||||
if self.is_running:
|
||||
future = self.stop()
|
||||
future.add_done_callback(lambda _future: asyncio.ensure_future(self.close()))
|
||||
else:
|
||||
asyncio.ensure_future(self.close())
|
||||
|
||||
def send_message(self, cmd, data):
|
||||
body = json.dumps({'cmd': cmd, 'data': data})
|
||||
for client in self.clients:
|
||||
try:
|
||||
client.write_message(body)
|
||||
except tornado.websocket.WebSocketClosedError:
|
||||
room_manager.del_client(self.room_id, client)
|
||||
|
||||
def send_message_if(self, can_send_func: Callable[['ChatHandler'], bool], cmd, data):
|
||||
body = json.dumps({'cmd': cmd, 'data': data})
|
||||
for client in filter(can_send_func, self.clients):
|
||||
try:
|
||||
client.write_message(body)
|
||||
except tornado.websocket.WebSocketClosedError:
|
||||
room_manager.del_client(self.room_id, client)
|
||||
|
||||
async def _on_receive_danmaku(self, danmaku: blivedm.DanmakuMessage):
|
||||
asyncio.ensure_future(self.__on_receive_danmaku(danmaku))
|
||||
|
||||
async def __on_receive_danmaku(self, danmaku: blivedm.DanmakuMessage):
|
||||
if danmaku.uid == self.room_owner_uid:
|
||||
author_type = 3 # 主播
|
||||
elif danmaku.admin:
|
||||
author_type = 2 # 房管
|
||||
elif danmaku.privilege_type != 0: # 1总督,2提督,3舰长
|
||||
author_type = 1 # 舰队
|
||||
else:
|
||||
author_type = 0
|
||||
|
||||
need_translate = self._need_translate(danmaku.msg)
|
||||
if need_translate:
|
||||
translation = models.translate.get_translation_from_cache(danmaku.msg)
|
||||
if translation is None:
|
||||
# 没有缓存,需要后面异步翻译后通知
|
||||
translation = ''
|
||||
else:
|
||||
need_translate = False
|
||||
else:
|
||||
translation = ''
|
||||
|
||||
id_ = uuid.uuid4().hex
|
||||
# 为了节省带宽用list而不是dict
|
||||
self.send_message(Command.ADD_TEXT, make_text_message(
|
||||
await models.avatar.get_avatar_url(danmaku.uid),
|
||||
int(danmaku.timestamp / 1000),
|
||||
danmaku.uname,
|
||||
author_type,
|
||||
danmaku.msg,
|
||||
danmaku.privilege_type,
|
||||
danmaku.msg_type,
|
||||
danmaku.user_level,
|
||||
danmaku.urank < 10000,
|
||||
danmaku.mobile_verify,
|
||||
0 if danmaku.room_id != self.room_id else danmaku.medal_level,
|
||||
id_,
|
||||
translation
|
||||
))
|
||||
|
||||
if need_translate:
|
||||
await self._translate_and_response(danmaku.msg, id_)
|
||||
|
||||
async def _on_receive_gift(self, gift: blivedm.GiftMessage):
|
||||
avatar_url = models.avatar.process_avatar_url(gift.face)
|
||||
models.avatar.update_avatar_cache(gift.uid, avatar_url)
|
||||
if gift.coin_type != 'gold': # 丢人
|
||||
return
|
||||
id_ = uuid.uuid4().hex
|
||||
self.send_message(Command.ADD_GIFT, {
|
||||
'id': id_,
|
||||
'avatarUrl': avatar_url,
|
||||
'timestamp': gift.timestamp,
|
||||
'authorName': gift.uname,
|
||||
'totalCoin': gift.total_coin,
|
||||
'giftName': gift.gift_name,
|
||||
'num': gift.num
|
||||
})
|
||||
|
||||
async def _on_buy_guard(self, message: blivedm.GuardBuyMessage):
|
||||
asyncio.ensure_future(self.__on_buy_guard(message))
|
||||
|
||||
async def __on_buy_guard(self, message: blivedm.GuardBuyMessage):
|
||||
id_ = uuid.uuid4().hex
|
||||
self.send_message(Command.ADD_MEMBER, {
|
||||
'id': id_,
|
||||
'avatarUrl': await models.avatar.get_avatar_url(message.uid),
|
||||
'timestamp': message.start_time,
|
||||
'authorName': message.username,
|
||||
'privilegeType': message.guard_level
|
||||
})
|
||||
|
||||
async def _on_super_chat(self, message: blivedm.SuperChatMessage):
|
||||
avatar_url = models.avatar.process_avatar_url(message.face)
|
||||
models.avatar.update_avatar_cache(message.uid, avatar_url)
|
||||
|
||||
need_translate = self._need_translate(message.message)
|
||||
if need_translate:
|
||||
translation = models.translate.get_translation_from_cache(message.message)
|
||||
if translation is None:
|
||||
# 没有缓存,需要后面异步翻译后通知
|
||||
translation = ''
|
||||
else:
|
||||
need_translate = False
|
||||
else:
|
||||
translation = ''
|
||||
|
||||
id_ = str(message.id)
|
||||
self.send_message(Command.ADD_SUPER_CHAT, {
|
||||
'id': id_,
|
||||
'avatarUrl': avatar_url,
|
||||
'timestamp': message.start_time,
|
||||
'authorName': message.uname,
|
||||
'price': message.price,
|
||||
'content': message.message,
|
||||
'translation': translation
|
||||
})
|
||||
|
||||
if need_translate:
|
||||
asyncio.ensure_future(self._translate_and_response(message.message, id_))
|
||||
|
||||
async def _on_super_chat_delete(self, message: blivedm.SuperChatDeleteMessage):
|
||||
self.send_message(Command.ADD_SUPER_CHAT, {
|
||||
'ids': list(map(str, message.ids))
|
||||
})
|
||||
|
||||
def _need_translate(self, text):
|
||||
cfg = config.get_config()
|
||||
return (
|
||||
cfg.enable_translate
|
||||
and (not cfg.allow_translate_rooms or self.room_id in cfg.allow_translate_rooms)
|
||||
and self.auto_translate_count > 0
|
||||
and models.translate.need_translate(text)
|
||||
)
|
||||
|
||||
async def _translate_and_response(self, text, msg_id):
|
||||
translation = await models.translate.translate(text)
|
||||
if translation is None:
|
||||
return
|
||||
self.send_message_if(
|
||||
lambda client: client.auto_translate,
|
||||
Command.UPDATE_TRANSLATION, make_translation_message(
|
||||
msg_id,
|
||||
translation
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def make_text_message(avatar_url, timestamp, author_name, author_type, content, privilege_type,
|
||||
is_gift_danmaku, author_level, is_newbie, is_mobile_verified, medal_level,
|
||||
id_, translation):
|
||||
def make_text_message_data(
|
||||
avatar_url: str = services.avatar.DEFAULT_AVATAR_URL,
|
||||
timestamp: int = None,
|
||||
author_name: str = '',
|
||||
author_type: int = 0,
|
||||
content: str = '',
|
||||
privilege_type: int = 0,
|
||||
is_gift_danmaku: bool = False,
|
||||
author_level: int = 1,
|
||||
is_newbie: bool = False,
|
||||
is_mobile_verified: bool = True,
|
||||
medal_level: int = 0,
|
||||
id_: str = None,
|
||||
translation: str = '',
|
||||
content_type: int = ContentType.TEXT,
|
||||
content_type_params: list = None,
|
||||
):
|
||||
# 为了节省带宽用list而不是dict
|
||||
return [
|
||||
# 0: avatarUrl
|
||||
avatar_url,
|
||||
# 1: timestamp
|
||||
timestamp,
|
||||
timestamp if timestamp is not None else int(time.time()),
|
||||
# 2: authorName
|
||||
author_name,
|
||||
# 3: authorType
|
||||
@@ -287,86 +89,33 @@ def make_text_message(avatar_url, timestamp, author_name, author_type, content,
|
||||
# 10: medalLevel
|
||||
medal_level,
|
||||
# 11: id
|
||||
id_,
|
||||
id_ if id_ is not None else uuid.uuid4().hex,
|
||||
# 12: translation
|
||||
translation
|
||||
translation,
|
||||
# 13: contentType
|
||||
content_type,
|
||||
# 14: contentTypeParams
|
||||
content_type_params if content_type_params is not None else [],
|
||||
]
|
||||
|
||||
|
||||
def make_translation_message(msg_id, translation):
|
||||
def make_emoticon_params(url):
|
||||
return [
|
||||
# 0: url
|
||||
url,
|
||||
]
|
||||
|
||||
|
||||
def make_translation_message_data(msg_id, translation):
|
||||
return [
|
||||
# 0: id
|
||||
msg_id,
|
||||
# 1: translation
|
||||
translation
|
||||
translation,
|
||||
]
|
||||
|
||||
|
||||
class RoomManager:
|
||||
def __init__(self):
|
||||
self._rooms: Dict[int, Room] = {}
|
||||
|
||||
async def add_client(self, room_id, client: 'ChatHandler'):
|
||||
if room_id not in self._rooms:
|
||||
if not await self._add_room(room_id):
|
||||
client.close()
|
||||
return
|
||||
room = self._rooms.get(room_id, None)
|
||||
if room is None:
|
||||
return
|
||||
|
||||
room.clients.append(client)
|
||||
logger.info('%d clients in room %s', len(room.clients), room_id)
|
||||
if client.auto_translate:
|
||||
room.auto_translate_count += 1
|
||||
|
||||
await client.on_join_room()
|
||||
|
||||
def del_client(self, room_id, client: 'ChatHandler'):
|
||||
room = self._rooms.get(room_id, None)
|
||||
if room is None:
|
||||
return
|
||||
|
||||
try:
|
||||
room.clients.remove(client)
|
||||
except ValueError:
|
||||
# _add_room未完成,没有执行到room.clients.append
|
||||
pass
|
||||
else:
|
||||
logger.info('%d clients in room %s', len(room.clients), room_id)
|
||||
if client.auto_translate:
|
||||
room.auto_translate_count = max(0, room.auto_translate_count - 1)
|
||||
|
||||
if not room.clients:
|
||||
self._del_room(room_id)
|
||||
|
||||
async def _add_room(self, room_id):
|
||||
if room_id in self._rooms:
|
||||
return True
|
||||
logger.info('Creating room %d', room_id)
|
||||
self._rooms[room_id] = room = Room(room_id)
|
||||
if await room.init_room():
|
||||
room.start()
|
||||
logger.info('%d rooms', len(self._rooms))
|
||||
return True
|
||||
else:
|
||||
self._del_room(room_id)
|
||||
return False
|
||||
|
||||
def _del_room(self, room_id):
|
||||
room = self._rooms.get(room_id, None)
|
||||
if room is None:
|
||||
return
|
||||
logger.info('Removing room %d', room_id)
|
||||
for client in room.clients:
|
||||
client.close()
|
||||
room.stop_and_close()
|
||||
self._rooms.pop(room_id, None)
|
||||
logger.info('%d rooms', len(self._rooms))
|
||||
|
||||
|
||||
# noinspection PyAbstractClass
|
||||
class ChatHandler(tornado.websocket.WebSocketHandler):
|
||||
class ChatHandler(tornado.websocket.WebSocketHandler): # noqa
|
||||
HEARTBEAT_INTERVAL = 10
|
||||
RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + 5
|
||||
|
||||
@@ -379,14 +128,14 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
|
||||
self.auto_translate = False
|
||||
|
||||
def open(self):
|
||||
logger.info('Websocket connected %s', self.request.remote_ip)
|
||||
logger.info('client=%s connected', self.request.remote_ip)
|
||||
self._heartbeat_timer_handle = asyncio.get_event_loop().call_later(
|
||||
self.HEARTBEAT_INTERVAL, self._on_send_heartbeat
|
||||
)
|
||||
self._refresh_receive_timeout_timer()
|
||||
|
||||
def _on_send_heartbeat(self):
|
||||
self.send_message(Command.HEARTBEAT, {})
|
||||
self.send_cmd_data(Command.HEARTBEAT, {})
|
||||
self._heartbeat_timer_handle = asyncio.get_event_loop().call_later(
|
||||
self.HEARTBEAT_INTERVAL, self._on_send_heartbeat
|
||||
)
|
||||
@@ -399,14 +148,14 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
|
||||
)
|
||||
|
||||
def _on_receive_timeout(self):
|
||||
logger.warning('Client %s timed out', self.request.remote_ip)
|
||||
logger.warning('client=%s timed out', self.request.remote_ip)
|
||||
self._receive_timeout_timer_handle = None
|
||||
self.close()
|
||||
|
||||
def on_close(self):
|
||||
logger.info('Websocket disconnected %s room: %s', self.request.remote_ip, str(self.room_id))
|
||||
logger.info('client=%s disconnected, room=%s', self.request.remote_ip, str(self.room_id))
|
||||
if self.has_joined_room:
|
||||
room_manager.del_client(self.room_id, self)
|
||||
services.chat.client_room_manager.del_client(self.room_id, self)
|
||||
if self._heartbeat_timer_handle is not None:
|
||||
self._heartbeat_timer_handle.cancel()
|
||||
self._heartbeat_timer_handle = None
|
||||
@@ -422,26 +171,31 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
|
||||
|
||||
body = json.loads(message)
|
||||
cmd = body['cmd']
|
||||
|
||||
if cmd == Command.HEARTBEAT:
|
||||
pass
|
||||
|
||||
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 is joining room %d', self.request.remote_ip, self.room_id)
|
||||
logger.info('client=%s joining room %d', self.request.remote_ip, self.room_id)
|
||||
try:
|
||||
cfg = body['data']['config']
|
||||
self.auto_translate = cfg['autoTranslate']
|
||||
self.auto_translate = bool(cfg['autoTranslate'])
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
asyncio.ensure_future(room_manager.add_client(self.room_id, self))
|
||||
services.chat.client_room_manager.add_client(self.room_id, self)
|
||||
asyncio.ensure_future(self._on_joined_room())
|
||||
|
||||
else:
|
||||
logger.warning('Unknown cmd, client: %s, cmd: %d, body: %s', self.request.remote_ip, cmd, body)
|
||||
except Exception:
|
||||
logger.exception('on_message error, client: %s, message: %s', self.request.remote_ip, message)
|
||||
logger.warning('client=%s unknown cmd=%d, body=%s', self.request.remote_ip, cmd, body)
|
||||
|
||||
except Exception: # noqa
|
||||
logger.exception('client=%s on_message error, message=%s', self.request.remote_ip, message)
|
||||
|
||||
# 跨域测试用
|
||||
def check_origin(self, origin):
|
||||
@@ -453,58 +207,43 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
|
||||
def has_joined_room(self):
|
||||
return self.room_id is not None
|
||||
|
||||
def send_message(self, cmd, data):
|
||||
body = json.dumps({'cmd': cmd, 'data': data})
|
||||
def send_cmd_data(self, cmd, data):
|
||||
self.send_body_no_raise(make_message_body(cmd, data))
|
||||
|
||||
def send_body_no_raise(self, body: Union[bytes, str, Dict[str, Any]]):
|
||||
try:
|
||||
self.write_message(body)
|
||||
except tornado.websocket.WebSocketClosedError:
|
||||
self.close()
|
||||
|
||||
async def on_join_room(self):
|
||||
if self.application.settings['debug']:
|
||||
await self.send_test_message()
|
||||
async def _on_joined_room(self):
|
||||
if self.settings['debug']:
|
||||
await self._send_test_message()
|
||||
|
||||
# 不允许自动翻译的提示
|
||||
if self.auto_translate:
|
||||
cfg = config.get_config()
|
||||
if cfg.allow_translate_rooms and self.room_id not in cfg.allow_translate_rooms:
|
||||
self.send_message(Command.ADD_TEXT, make_text_message(
|
||||
models.avatar.DEFAULT_AVATAR_URL,
|
||||
int(time.time()),
|
||||
'blivechat',
|
||||
2,
|
||||
'Translation is not allowed in this room. Please download to use translation',
|
||||
0,
|
||||
False,
|
||||
60,
|
||||
False,
|
||||
True,
|
||||
0,
|
||||
uuid.uuid4().hex,
|
||||
''
|
||||
self.send_cmd_data(Command.ADD_TEXT, make_text_message_data(
|
||||
author_name='blivechat',
|
||||
author_type=2,
|
||||
content='Translation is not allowed in this room. Please download to use translation',
|
||||
author_level=60,
|
||||
))
|
||||
|
||||
# 测试用
|
||||
async def send_test_message(self):
|
||||
async def _send_test_message(self):
|
||||
base_data = {
|
||||
'avatarUrl': await models.avatar.get_avatar_url(300474),
|
||||
'avatarUrl': await services.avatar.get_avatar_url(300474),
|
||||
'timestamp': int(time.time()),
|
||||
'authorName': 'xfgryujk',
|
||||
}
|
||||
text_data = make_text_message(
|
||||
base_data['avatarUrl'],
|
||||
base_data['timestamp'],
|
||||
base_data['authorName'],
|
||||
0,
|
||||
'我能吞下玻璃而不伤身体',
|
||||
0,
|
||||
False,
|
||||
20,
|
||||
False,
|
||||
True,
|
||||
0,
|
||||
uuid.uuid4().hex,
|
||||
''
|
||||
text_data = make_text_message_data(
|
||||
avatar_url=base_data['avatarUrl'],
|
||||
timestamp=base_data['timestamp'],
|
||||
author_name=base_data['authorName'],
|
||||
content='我能吞下玻璃而不伤身体',
|
||||
author_level=60,
|
||||
)
|
||||
member_data = {
|
||||
**base_data,
|
||||
@@ -525,33 +264,30 @@ class ChatHandler(tornado.websocket.WebSocketHandler):
|
||||
'content': 'The quick brown fox jumps over the lazy dog',
|
||||
'translation': ''
|
||||
}
|
||||
self.send_message(Command.ADD_TEXT, text_data)
|
||||
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."
|
||||
text_data[11] = uuid.uuid4().hex
|
||||
self.send_message(Command.ADD_TEXT, text_data)
|
||||
self.send_message(Command.ADD_MEMBER, member_data)
|
||||
self.send_message(Command.ADD_SUPER_CHAT, sc_data)
|
||||
self.send_cmd_data(Command.ADD_TEXT, text_data)
|
||||
self.send_cmd_data(Command.ADD_MEMBER, member_data)
|
||||
self.send_cmd_data(Command.ADD_SUPER_CHAT, sc_data)
|
||||
sc_data['id'] = str(random.randint(1, 65535))
|
||||
sc_data['price'] = 100
|
||||
sc_data['content'] = '敏捷的棕色狐狸跳过了懒狗'
|
||||
self.send_message(Command.ADD_SUPER_CHAT, sc_data)
|
||||
self.send_cmd_data(Command.ADD_SUPER_CHAT, sc_data)
|
||||
# self.send_message(Command.DEL_SUPER_CHAT, {'ids': [sc_data['id']]})
|
||||
self.send_message(Command.ADD_GIFT, gift_data)
|
||||
self.send_cmd_data(Command.ADD_GIFT, gift_data)
|
||||
gift_data['id'] = uuid.uuid4().hex
|
||||
gift_data['totalCoin'] = 1245000
|
||||
gift_data['giftName'] = '小电视飞船'
|
||||
self.send_message(Command.ADD_GIFT, gift_data)
|
||||
self.send_cmd_data(Command.ADD_GIFT, gift_data)
|
||||
|
||||
|
||||
# noinspection PyAbstractClass
|
||||
class RoomInfoHandler(api.base.ApiHandler):
|
||||
_host_server_list_cache = blivedm.DEFAULT_DANMAKU_SERVER_LIST
|
||||
|
||||
class RoomInfoHandler(api.base.ApiHandler): # noqa
|
||||
async def get(self):
|
||||
room_id = int(self.get_query_argument('roomId'))
|
||||
logger.info('Client %s is getting room info %d', self.request.remote_ip, room_id)
|
||||
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)
|
||||
if owner_uid == 0:
|
||||
@@ -569,61 +305,37 @@ class RoomInfoHandler(api.base.ApiHandler):
|
||||
@staticmethod
|
||||
async def _get_room_info(room_id):
|
||||
try:
|
||||
async with _http_session.get(blivedm.ROOM_INIT_URL, params={'room_id': room_id}
|
||||
) as res:
|
||||
async with utils.request.http_session.get(
|
||||
blivedm_client.ROOM_INIT_URL, params={'room_id': room_id}
|
||||
) as res:
|
||||
if res.status != 200:
|
||||
logger.warning('room %d _get_room_info failed: %d %s', room_id,
|
||||
logger.warning('room=%d _get_room_info failed: %d %s', room_id,
|
||||
res.status, res.reason)
|
||||
return room_id, 0
|
||||
data = await res.json()
|
||||
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
||||
logger.exception('room %d _get_room_info failed', room_id)
|
||||
logger.exception('room=%d _get_room_info failed', room_id)
|
||||
return room_id, 0
|
||||
|
||||
if data['code'] != 0:
|
||||
logger.warning('room %d _get_room_info failed: %s', room_id, data['message'])
|
||||
logger.warning('room=%d _get_room_info failed: %s', room_id, data['message'])
|
||||
return room_id, 0
|
||||
|
||||
room_info = data['data']['room_info']
|
||||
return room_info['room_id'], room_info['uid']
|
||||
|
||||
@classmethod
|
||||
async def _get_server_host_list(cls, _room_id):
|
||||
return cls._host_server_list_cache
|
||||
|
||||
@staticmethod
|
||||
async def _get_server_host_list(_room_id):
|
||||
# 连接其他host必须要key
|
||||
# try:
|
||||
# async with _http_session.get(blivedm.DANMAKU_SERVER_CONF_URL, params={'id': room_id, 'type': 0}
|
||||
# ) as res:
|
||||
# if res.status != 200:
|
||||
# logger.warning('room %d _get_server_host_list failed: %d %s', room_id,
|
||||
# res.status, res.reason)
|
||||
# return cls._host_server_list_cache
|
||||
# data = await res.json()
|
||||
# except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
||||
# logger.exception('room %d _get_server_host_list failed', room_id)
|
||||
# return cls._host_server_list_cache
|
||||
#
|
||||
# if data['code'] != 0:
|
||||
# logger.warning('room %d _get_server_host_list failed: %s', room_id, data['message'])
|
||||
# return cls._host_server_list_cache
|
||||
#
|
||||
# host_server_list = data['data']['host_list']
|
||||
# if not host_server_list:
|
||||
# logger.warning('room %d _get_server_host_list failed: host_server_list is empty')
|
||||
# return cls._host_server_list_cache
|
||||
#
|
||||
# cls._host_server_list_cache = host_server_list
|
||||
# return host_server_list
|
||||
return blivedm_client.DEFAULT_DANMAKU_SERVER_LIST
|
||||
|
||||
|
||||
# noinspection PyAbstractClass
|
||||
class AvatarHandler(api.base.ApiHandler):
|
||||
class AvatarHandler(api.base.ApiHandler): # noqa
|
||||
async def get(self):
|
||||
uid = int(self.get_query_argument('uid'))
|
||||
avatar_url = await models.avatar.get_avatar_url_or_none(uid)
|
||||
avatar_url = await services.avatar.get_avatar_url_or_none(uid)
|
||||
if avatar_url is None:
|
||||
avatar_url = models.avatar.DEFAULT_AVATAR_URL
|
||||
avatar_url = services.avatar.DEFAULT_AVATAR_URL
|
||||
# 缓存3分钟
|
||||
self.set_header('Cache-Control', 'private, max-age=180')
|
||||
else:
|
||||
|
||||
53
api/main.py
@@ -1,4 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
import tornado.web
|
||||
|
||||
@@ -6,9 +10,13 @@ import api.base
|
||||
import config
|
||||
import update
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# noinspection PyAbstractClass
|
||||
class MainHandler(tornado.web.StaticFileHandler):
|
||||
EMOTICON_UPLOAD_PATH = os.path.join(config.DATA_PATH, 'emoticons')
|
||||
EMOTICON_BASE_URL = '/emoticons'
|
||||
|
||||
|
||||
class MainHandler(tornado.web.StaticFileHandler): # noqa
|
||||
"""为了使用Vue Router的history模式,把不存在的文件请求转发到index.html"""
|
||||
async def get(self, path, include_body=True):
|
||||
try:
|
||||
@@ -20,14 +28,51 @@ class MainHandler(tornado.web.StaticFileHandler):
|
||||
await super().get('index.html', include_body)
|
||||
|
||||
|
||||
# noinspection PyAbstractClass
|
||||
class ServerInfoHandler(api.base.ApiHandler):
|
||||
class ServerInfoHandler(api.base.ApiHandler): # noqa
|
||||
async def get(self):
|
||||
cfg = config.get_config()
|
||||
self.write({
|
||||
'version': update.VERSION,
|
||||
'config': {
|
||||
'enableTranslate': cfg.enable_translate,
|
||||
'enableUploadFile': cfg.enable_upload_file,
|
||||
'loaderUrl': cfg.loader_url
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
class UploadEmoticonHandler(api.base.ApiHandler): # noqa
|
||||
async def post(self):
|
||||
cfg = config.get_config()
|
||||
if not cfg.enable_upload_file:
|
||||
raise tornado.web.HTTPError(403)
|
||||
|
||||
try:
|
||||
file = self.request.files['file'][0]
|
||||
except LookupError:
|
||||
raise tornado.web.MissingArgumentError('file')
|
||||
if len(file.body) > 1024 * 1024:
|
||||
raise tornado.web.HTTPError(413, 'file is too large, size=%d', len(file.body))
|
||||
if not file.content_type.lower().startswith('image/'):
|
||||
raise tornado.web.HTTPError(415)
|
||||
|
||||
url = await asyncio.get_event_loop().run_in_executor(
|
||||
None, self._save_file, file.body, self.request.remote_ip
|
||||
)
|
||||
self.write({
|
||||
'url': url
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _save_file(body, client):
|
||||
md5 = hashlib.md5(body).hexdigest()
|
||||
filename = md5 + '.png'
|
||||
path = os.path.join(EMOTICON_UPLOAD_PATH, filename)
|
||||
logger.info('client=%s uploaded file, path=%s, size=%d', client, path, len(body))
|
||||
|
||||
tmp_path = path + '.tmp'
|
||||
with open(tmp_path, 'wb') as f:
|
||||
f.write(body)
|
||||
os.replace(tmp_path, path)
|
||||
|
||||
return f'{EMOTICON_BASE_URL}/{filename}'
|
||||
|
||||
2
blivedm
96
config.py
@@ -1,5 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import configparser
|
||||
import logging
|
||||
import os
|
||||
@@ -7,9 +6,13 @@ from typing import *
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_PATH = os.path.dirname(os.path.realpath(__file__))
|
||||
WEB_ROOT = os.path.join(BASE_PATH, 'frontend', 'dist')
|
||||
DATA_PATH = os.path.join(BASE_PATH, 'data')
|
||||
|
||||
CONFIG_PATH_LIST = [
|
||||
os.path.join('data', 'config.ini'),
|
||||
os.path.join('data', 'config.example.ini')
|
||||
os.path.join(DATA_PATH, 'config.ini'),
|
||||
os.path.join(DATA_PATH, 'config.example.ini')
|
||||
]
|
||||
|
||||
_config: Optional['AppConfig'] = None
|
||||
@@ -49,6 +52,7 @@ class AppConfig:
|
||||
self.database_url = 'sqlite:///data/database.db'
|
||||
self.tornado_xheaders = False
|
||||
self.loader_url = ''
|
||||
self.enable_upload_file = True
|
||||
|
||||
self.fetch_avatar_interval = 3.5
|
||||
self.fetch_avatar_max_queue_size = 2
|
||||
@@ -62,66 +66,72 @@ class AppConfig:
|
||||
def load(self, path):
|
||||
try:
|
||||
config = configparser.ConfigParser()
|
||||
config.read(path, 'utf-8')
|
||||
config.read(path, 'utf-8-sig')
|
||||
|
||||
self._load_app_config(config)
|
||||
self._load_translator_configs(config)
|
||||
except Exception:
|
||||
except Exception: # noqa
|
||||
logger.exception('Failed to load config:')
|
||||
return False
|
||||
return True
|
||||
|
||||
def _load_app_config(self, config):
|
||||
def _load_app_config(self, config: configparser.ConfigParser):
|
||||
app_section = config['app']
|
||||
self.database_url = app_section['database_url']
|
||||
self.tornado_xheaders = app_section.getboolean('tornado_xheaders')
|
||||
self.loader_url = app_section['loader_url']
|
||||
self.database_url = app_section.get('database_url', self.database_url)
|
||||
self.tornado_xheaders = app_section.getboolean('tornado_xheaders', fallback=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.fetch_avatar_interval = app_section.getfloat('fetch_avatar_interval')
|
||||
self.fetch_avatar_max_queue_size = app_section.getint('fetch_avatar_max_queue_size')
|
||||
self.avatar_cache_size = app_section.getint('avatar_cache_size')
|
||||
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.enable_translate = app_section.getboolean('enable_translate')
|
||||
self.allow_translate_rooms = _str_to_list(app_section['allow_translate_rooms'], int, set)
|
||||
self.translation_cache_size = app_section.getint('translation_cache_size')
|
||||
self.enable_translate = app_section.getboolean('enable_translate', fallback=self.enable_translate)
|
||||
self.allow_translate_rooms = _str_to_list(app_section.get('allow_translate_rooms', ''), int, set)
|
||||
self.translation_cache_size = app_section.getint('translation_cache_size', self.translation_cache_size)
|
||||
|
||||
def _load_translator_configs(self, config):
|
||||
def _load_translator_configs(self, config: configparser.ConfigParser):
|
||||
app_section = config['app']
|
||||
section_names = _str_to_list(app_section['translator_configs'])
|
||||
section_names = _str_to_list(app_section.get('translator_configs', ''))
|
||||
translator_configs = []
|
||||
for section_name in section_names:
|
||||
section = config[section_name]
|
||||
type_ = section['type']
|
||||
try:
|
||||
section = config[section_name]
|
||||
type_ = section['type']
|
||||
|
||||
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']
|
||||
translator_config['target_language'] = section['target_language']
|
||||
elif type_ == 'BilibiliTranslateFree':
|
||||
pass
|
||||
elif type_ == 'TencentTranslate':
|
||||
translator_config['source_language'] = section['source_language']
|
||||
translator_config['target_language'] = section['target_language']
|
||||
translator_config['secret_id'] = section['secret_id']
|
||||
translator_config['secret_key'] = section['secret_key']
|
||||
translator_config['region'] = section['region']
|
||||
elif type_ == 'BaiduTranslate':
|
||||
translator_config['source_language'] = section['source_language']
|
||||
translator_config['target_language'] = section['target_language']
|
||||
translator_config['app_id'] = section['app_id']
|
||||
translator_config['secret'] = section['secret']
|
||||
else:
|
||||
raise ValueError(f'Invalid translator type: {type_}')
|
||||
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']
|
||||
translator_config['target_language'] = section['target_language']
|
||||
elif type_ == 'BilibiliTranslateFree':
|
||||
pass
|
||||
elif type_ == 'TencentTranslate':
|
||||
translator_config['source_language'] = section['source_language']
|
||||
translator_config['target_language'] = section['target_language']
|
||||
translator_config['secret_id'] = section['secret_id']
|
||||
translator_config['secret_key'] = section['secret_key']
|
||||
translator_config['region'] = section['region']
|
||||
elif type_ == 'BaiduTranslate':
|
||||
translator_config['source_language'] = section['source_language']
|
||||
translator_config['target_language'] = section['target_language']
|
||||
translator_config['app_id'] = section['app_id']
|
||||
translator_config['secret'] = section['secret']
|
||||
else:
|
||||
raise ValueError(f'Invalid translator type: {type_}')
|
||||
except Exception: # noqa
|
||||
logger.exception('Failed to load translator=%s config:', section_name)
|
||||
continue
|
||||
|
||||
translator_configs.append(translator_config)
|
||||
self.translator_configs = translator_configs
|
||||
|
||||
|
||||
def _str_to_list(value, item_type: Type=str, container_type: Type=list):
|
||||
def _str_to_list(value, item_type: Type = str, container_type: Type = list):
|
||||
value = value.strip()
|
||||
if value == '':
|
||||
return container_type()
|
||||
|
||||
@@ -15,9 +15,13 @@ 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
|
||||
|
||||
# 允许上传自定义表情文件
|
||||
# Enable uploading custom emote file
|
||||
enable_upload_file = true
|
||||
|
||||
|
||||
# 获取头像间隔时间(秒)。如果小于3秒有很大概率被服务器拉黑
|
||||
# Interval between fetching avatar (s). At least 3 seconds is recommended
|
||||
# Interval between fetching avatars (seconds). At least 3 seconds is recommended
|
||||
fetch_avatar_interval = 3.5
|
||||
|
||||
# 获取头像最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间
|
||||
@@ -34,7 +38,7 @@ avatar_cache_size = 50000
|
||||
enable_translate = true
|
||||
|
||||
# 允许翻译的房间ID,以逗号分隔。如果为空,允许所有房间
|
||||
# Comma separated room IDs in which translation are not allowed. If empty, all are allowed
|
||||
# Comma separated room IDs in which translation are allowed. If empty, all rooms are allowed
|
||||
# Example: allow_translate_rooms = 4895312,22347054,21693691
|
||||
allow_translate_rooms =
|
||||
|
||||
@@ -98,18 +102,18 @@ query_interval = 0.333
|
||||
# 最大队列长度,注意最长等待时间等于 最大队列长度 * 请求间隔时间
|
||||
max_queue_size = 30
|
||||
|
||||
# 自动:auto;中文:zh;日语:jp;英语:en;韩语:kr
|
||||
# 自动:auto;中文:zh;日语:ja;英语:en;韩语:ko
|
||||
# 完整语言列表见文档:https://cloud.tencent.com/document/product/551/15619
|
||||
# 源语言
|
||||
source_language = zh
|
||||
# 目标语言
|
||||
target_language = jp
|
||||
target_language = ja
|
||||
|
||||
# 腾讯云API密钥
|
||||
secret_id =
|
||||
secret_key =
|
||||
|
||||
# 腾讯云地域参数,用来标识希望操作哪个地域的数据
|
||||
# 腾讯云地域参数,用来标识希望操作哪个地域的数据,建议按照运行blivechat的机器所在地区就近选择
|
||||
# 北京:ap-beijing;上海:ap-shanghai;香港:ap-hongkong;首尔:ap-seoul
|
||||
# 完整地域列表见文档:https://cloud.tencent.com/document/api/551/15615#.E5.9C.B0.E5.9F.9F.E5.88.97.E8.A1.A8
|
||||
region = ap-shanghai
|
||||
|
||||
0
data/emoticons/.gitkeep
Normal file
2
frontend/.eslintignore
Normal file
@@ -0,0 +1,2 @@
|
||||
brotli_decode.js
|
||||
pronunciation/dict*.js
|
||||
79
frontend/.eslintrc.js
Normal file
@@ -0,0 +1,79 @@
|
||||
module.exports = {
|
||||
"root": true,
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"parser": "babel-eslint"
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/essential",
|
||||
"eslint:recommended"
|
||||
],
|
||||
"rules": {
|
||||
"array-bracket-spacing": ["error", "never"], // 数组括号内不加空格
|
||||
"arrow-parens": ["error", "as-needed"], // 箭头函数单个参数不加括号
|
||||
"arrow-spacing": "error", // 箭头前后加空格
|
||||
"block-spacing": "error", // 块大括号内加空格
|
||||
"brace-style": "error", // 大括号不独占一行
|
||||
"comma-spacing": "error", // 逗号前面不加空格,后面加空格
|
||||
"comma-style": "error", // 逗号在语句后面而不是下一条的前面
|
||||
"computed-property-spacing": "error", // 计算属性名前后不加空格
|
||||
"curly": "error", // 禁止省略大括号
|
||||
"dot-notation": "error", // 使用点访问成员
|
||||
"eol-last": "error", // 文件末尾加换行符
|
||||
"func-call-spacing": "error", // 调用函数名和括号间不加空格
|
||||
"func-style": ["error", "declaration", { "allowArrowFunctions": true }], // 使用函数定义语法,而不是把函数表达式赋值到变量
|
||||
"indent": ["error", 2], // 缩进2空格
|
||||
"key-spacing": ["error", { "mode": "minimum" }],
|
||||
"keyword-spacing": "error", // 关键词前后加空格
|
||||
"lines-between-class-members": "error", // 类成员定义间加空格
|
||||
"max-lines-per-function": ["error", 150], // 每个函数最多行数
|
||||
"max-nested-callbacks": ["error", 3], // 每个函数最多嵌套回调数
|
||||
"new-parens": "error", // new调用构造函数加空格
|
||||
"no-array-constructor": "error", // 使用数组字面量,而不是数组构造函数
|
||||
"no-floating-decimal": "error", // 禁止省略浮点数首尾的0
|
||||
"no-implicit-coercion": "error", // 禁止隐式转换
|
||||
"no-empty": ["error", { "allowEmptyCatch": true }], // 禁止空的块,除了catch
|
||||
"no-extra-parens": ["error", "all", { "nestedBinaryExpressions": false }], // 禁止多余的括号
|
||||
"no-labels": "error", // 禁止使用标签
|
||||
"no-lone-blocks": "error", // 禁止没用的块
|
||||
"no-mixed-operators": "error", // 禁止混用不同优先级的操作符而不加括号
|
||||
"no-multi-spaces": ["error", { "ignoreEOLComments": true }], // 禁止多个空格,除了行尾注释前
|
||||
"no-multiple-empty-lines": "error", // 最多2个连续空行
|
||||
"no-nested-ternary": "error", // 禁止嵌套三元表达式
|
||||
"no-sequences": "error", // 禁止使用逗号操作符
|
||||
"no-tabs": "error", // 禁止使用tab
|
||||
"no-trailing-spaces": ["error", { "skipBlankLines": true }], // 禁止行尾的空格,除了空行
|
||||
"no-unused-expressions": "error", // 禁止没用的表达式
|
||||
"no-useless-concat": "error", // 禁止没用的字符串连接
|
||||
"no-useless-rename": "error", // 禁止没用的模块导入重命名、解构赋值重命名
|
||||
"no-useless-return": "error", // 禁止没用的return
|
||||
"no-var": "error", // 禁止使用var声明变量
|
||||
"no-void": "error", // 禁止使用void
|
||||
"no-whitespace-before-property": "error", // 禁止访问属性的点前后加空格
|
||||
"object-curly-spacing": ["error", "always"], // 对象字面量括号内加空格
|
||||
"operator-assignment": "error", // 尽量使用+=
|
||||
"operator-linebreak": ["error", "before"], // 操作符放行首
|
||||
"prefer-object-spread": "error", // 使用{...obj},而不是Object.assign
|
||||
"prefer-rest-params": "error", // 使用...args,而不是arguments
|
||||
"prefer-spread": "error", // 使用func(...args),而不是apply
|
||||
"prefer-template": "error", // 使用模板字符串,而不是字符串连接
|
||||
"rest-spread-spacing": ["error", "never"], // 解包操作符不加空格
|
||||
"semi": ["error", "never"], // 禁止使用多余的分号
|
||||
"semi-spacing": "error", // 分号前面不加空格,后面加空格
|
||||
"semi-style": "error", // 分号在语句后面而不是下一条的前面
|
||||
"space-before-blocks": "error", // 块大括号前加空格
|
||||
"space-before-function-paren": ["error", "never"], // 函数定义名称和括号间不加空格
|
||||
"space-in-parens": "error", // 括号内不加空格
|
||||
"space-infix-ops": "error", // 二元操作符前后加空格
|
||||
"space-unary-ops": "error", // 关键词一元操作符后加空格,符号一元操作符不加
|
||||
"spaced-comment": ["error", "always", { "block": { "balanced": true } }], // 注释前面加空格
|
||||
"template-curly-spacing": "error", // 模板字符串中变量大括号内不加空格
|
||||
|
||||
"no-shadow": "warn", // 变量名和外部作用域重复
|
||||
|
||||
"no-console": "off", // 线上尽量不要用console输出,看不到的
|
||||
}
|
||||
}
|
||||
14
frontend/jsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2015",
|
||||
"module": "esnext",
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*.js",
|
||||
"./src/**/*.vue"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "blivechat",
|
||||
"version": "0.1.0",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
@@ -13,7 +13,6 @@
|
||||
"downloadjs": "^1.4.7",
|
||||
"element-ui": "^2.9.1",
|
||||
"lodash": "^4.17.19",
|
||||
"pako": "^1.0.11",
|
||||
"vue": "^2.6.10",
|
||||
"vue-i18n": "^8.11.2",
|
||||
"vue-router": "^3.0.6"
|
||||
@@ -28,20 +27,6 @@
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"vue-template-compiler": "^2.5.21"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/essential",
|
||||
"eslint:recommended"
|
||||
],
|
||||
"rules": {},
|
||||
"parserOptions": {
|
||||
"parser": "babel-eslint"
|
||||
}
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"autoprefixer": {}
|
||||
|
||||
BIN
frontend/public/static/img/emoticons/233.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
frontend/public/static/img/emoticons/lipu.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
frontend/public/static/img/emoticons/miaoa.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 301 KiB |
2287
frontend/src/api/chat/ChatClientDirect/brotli_decode.js
Normal file
@@ -1,14 +1,15 @@
|
||||
import axios from 'axios'
|
||||
import * as pako from 'pako'
|
||||
|
||||
import {getUuid4Hex} from '@/utils'
|
||||
import * as avatar from './avatar'
|
||||
import { BrotliDecode } from './brotli_decode'
|
||||
import { getUuid4Hex } from '@/utils'
|
||||
import * as avatar from '../avatar'
|
||||
|
||||
const HEADER_SIZE = 16
|
||||
|
||||
// const WS_BODY_PROTOCOL_VERSION_INFLATE = 0
|
||||
// const WS_BODY_PROTOCOL_VERSION_NORMAL = 1
|
||||
const WS_BODY_PROTOCOL_VERSION_DEFLATE = 2
|
||||
// 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
|
||||
|
||||
// const OP_HANDSHAKE = 0
|
||||
// const OP_HANDSHAKE_REPLY = 1
|
||||
@@ -32,19 +33,22 @@ const OP_AUTH_REPLY = 8
|
||||
// const MinBusinessOp = 1000
|
||||
// const MaxBusinessOp = 10000
|
||||
|
||||
const AUTH_REPLY_CODE_OK = 0
|
||||
// const AUTH_REPLY_CODE_TOKEN_ERROR = -101
|
||||
|
||||
const HEARTBEAT_INTERVAL = 10 * 1000
|
||||
const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + 5 * 1000
|
||||
const RECEIVE_TIMEOUT = HEARTBEAT_INTERVAL + (5 * 1000)
|
||||
|
||||
let textEncoder = new TextEncoder()
|
||||
let textDecoder = new TextDecoder()
|
||||
|
||||
export default class ChatClientDirect {
|
||||
constructor (roomId) {
|
||||
constructor(roomId) {
|
||||
// 调用initRoom后初始化,如果失败,使用这里的默认值
|
||||
this.roomId = roomId
|
||||
this.roomOwnerUid = 0
|
||||
this.hostServerList = [
|
||||
{host: "broadcastlv.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244}
|
||||
{ host: "broadcastlv.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244 }
|
||||
]
|
||||
|
||||
this.onAddText = null
|
||||
@@ -61,24 +65,24 @@ export default class ChatClientDirect {
|
||||
this.receiveTimeoutTimerId = null
|
||||
}
|
||||
|
||||
async start () {
|
||||
async start() {
|
||||
await this.initRoom()
|
||||
this.wsConnect()
|
||||
}
|
||||
|
||||
stop () {
|
||||
stop() {
|
||||
this.isDestroying = true
|
||||
if (this.websocket) {
|
||||
this.websocket.close()
|
||||
}
|
||||
}
|
||||
|
||||
async initRoom () {
|
||||
async initRoom() {
|
||||
let res
|
||||
try {
|
||||
res = (await axios.get('/api/room_info', {params: {
|
||||
res = (await axios.get('/api/room_info', { params: {
|
||||
roomId: this.roomId
|
||||
}})).data
|
||||
} })).data
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
@@ -89,7 +93,7 @@ export default class ChatClientDirect {
|
||||
}
|
||||
}
|
||||
|
||||
makePacket (data, operation) {
|
||||
makePacket(data, operation) {
|
||||
let body = textEncoder.encode(JSON.stringify(data))
|
||||
let header = new ArrayBuffer(HEADER_SIZE)
|
||||
let headerView = new DataView(header)
|
||||
@@ -101,19 +105,18 @@ export default class ChatClientDirect {
|
||||
return new Blob([header, body])
|
||||
}
|
||||
|
||||
sendAuth () {
|
||||
sendAuth() {
|
||||
let authParams = {
|
||||
uid: 0,
|
||||
roomid: this.roomId,
|
||||
protover: 2,
|
||||
protover: 3,
|
||||
platform: 'web',
|
||||
clientver: '1.14.3',
|
||||
type: 2
|
||||
}
|
||||
this.websocket.send(this.makePacket(authParams, OP_AUTH))
|
||||
}
|
||||
|
||||
wsConnect () {
|
||||
wsConnect() {
|
||||
if (this.isDestroying) {
|
||||
return
|
||||
}
|
||||
@@ -126,13 +129,13 @@ export default class ChatClientDirect {
|
||||
this.websocket.onmessage = this.onWsMessage.bind(this)
|
||||
}
|
||||
|
||||
onWsOpen () {
|
||||
onWsOpen() {
|
||||
this.sendAuth()
|
||||
this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), HEARTBEAT_INTERVAL)
|
||||
this.refreshReceiveTimeoutTimer()
|
||||
}
|
||||
|
||||
sendHeartbeat () {
|
||||
sendHeartbeat() {
|
||||
this.websocket.send(this.makePacket({}, OP_HEARTBEAT))
|
||||
}
|
||||
|
||||
@@ -144,8 +147,15 @@ export default class ChatClientDirect {
|
||||
}
|
||||
|
||||
onReceiveTimeout() {
|
||||
window.console.warn('接收消息超时')
|
||||
this.receiveTimeoutTimerId = null
|
||||
console.warn('接收消息超时')
|
||||
this.discardWebsocket()
|
||||
}
|
||||
|
||||
discardWebsocket() {
|
||||
if (this.receiveTimeoutTimerId) {
|
||||
window.clearTimeout(this.receiveTimeoutTimerId)
|
||||
this.receiveTimeoutTimerId = null
|
||||
}
|
||||
|
||||
// 直接丢弃阻塞的websocket,不等onclose回调了
|
||||
this.websocket.onopen = this.websocket.onclose = this.websocket.onmessage = null
|
||||
@@ -153,7 +163,7 @@ export default class ChatClientDirect {
|
||||
this.onWsClose()
|
||||
}
|
||||
|
||||
onWsClose () {
|
||||
onWsClose() {
|
||||
this.websocket = null
|
||||
if (this.heartbeatTimerId) {
|
||||
window.clearInterval(this.heartbeatTimerId)
|
||||
@@ -167,88 +177,124 @@ export default class ChatClientDirect {
|
||||
if (this.isDestroying) {
|
||||
return
|
||||
}
|
||||
window.console.warn(`掉线重连中${++this.retryCount}`)
|
||||
this.retryCount++
|
||||
console.warn('掉线重连中', this.retryCount)
|
||||
window.setTimeout(this.wsConnect.bind(this), 1000)
|
||||
}
|
||||
|
||||
onWsMessage (event) {
|
||||
onWsMessage(event) {
|
||||
this.refreshReceiveTimeoutTimer()
|
||||
this.retryCount = 0
|
||||
if (!(event.data instanceof ArrayBuffer)) {
|
||||
window.console.warn('未知的websocket消息:', event.data)
|
||||
console.warn('未知的websocket消息类型,data=', event.data)
|
||||
return
|
||||
}
|
||||
|
||||
let data = new Uint8Array(event.data)
|
||||
this.handlerMessage(data)
|
||||
this.parseWsMessage(data)
|
||||
|
||||
// 至少成功处理1条消息
|
||||
this.retryCount = 0
|
||||
}
|
||||
|
||||
handlerMessage (data) {
|
||||
parseWsMessage(data) {
|
||||
let offset = 0
|
||||
while (offset < data.byteLength) {
|
||||
let dataView = new DataView(data.buffer, offset)
|
||||
let packLen = dataView.getUint32(0)
|
||||
// let rawHeaderSize = dataView.getUint16(4)
|
||||
let ver = dataView.getUint16(6)
|
||||
let operation = dataView.getUint32(8)
|
||||
// let seqId = dataView.getUint32(12)
|
||||
|
||||
switch (operation) {
|
||||
case OP_HEARTBEAT_REPLY: {
|
||||
// 人气值没用
|
||||
break
|
||||
let dataView = new DataView(data.buffer)
|
||||
let packLen = dataView.getUint32(0)
|
||||
let rawHeaderSize = dataView.getUint16(4)
|
||||
// let ver = dataView.getUint16(6)
|
||||
let operation = dataView.getUint32(8)
|
||||
// let seqId = dataView.getUint32(12)
|
||||
|
||||
switch (operation) {
|
||||
case OP_AUTH_REPLY:
|
||||
case OP_SEND_MSG_REPLY: {
|
||||
// 业务消息,可能有多个包一起发,需要分包
|
||||
while (true) { // eslint-disable-line no-constant-condition
|
||||
let body = new Uint8Array(data.buffer, offset + rawHeaderSize, packLen - rawHeaderSize)
|
||||
this.parseBusinessMessage(dataView, body)
|
||||
|
||||
offset += packLen
|
||||
if (offset >= data.byteLength) {
|
||||
break
|
||||
}
|
||||
|
||||
dataView = new DataView(data.buffer, offset)
|
||||
packLen = dataView.getUint32(0)
|
||||
rawHeaderSize = dataView.getUint16(4)
|
||||
}
|
||||
case OP_SEND_MSG_REPLY: {
|
||||
let body = new Uint8Array(data.buffer, offset + HEADER_SIZE, packLen - HEADER_SIZE)
|
||||
if (ver == WS_BODY_PROTOCOL_VERSION_DEFLATE) {
|
||||
body = pako.inflate(body)
|
||||
this.handlerMessage(body)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
case OP_HEARTBEAT_REPLY: {
|
||||
// 服务器心跳包,包含人气值,这里没用
|
||||
break
|
||||
}
|
||||
default: {
|
||||
// 未知消息
|
||||
let body = new Uint8Array(data.buffer, offset + rawHeaderSize, packLen - rawHeaderSize)
|
||||
console.warn('未知包类型,operation=', operation, dataView, body)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parseBusinessMessage(dataView, body) {
|
||||
let ver = dataView.getUint16(6)
|
||||
let operation = dataView.getUint32(8)
|
||||
|
||||
switch (operation) {
|
||||
case OP_SEND_MSG_REPLY: {
|
||||
// 业务消息
|
||||
if (ver == WS_BODY_PROTOCOL_VERSION_BROTLI) {
|
||||
// 压缩过的先解压
|
||||
body = BrotliDecode(body)
|
||||
this.parseWsMessage(body)
|
||||
} else {
|
||||
// 没压缩过的直接反序列化
|
||||
if (body.length !== 0) {
|
||||
try {
|
||||
body = JSON.parse(textDecoder.decode(body))
|
||||
this.handlerCommand(body)
|
||||
} catch (e) {
|
||||
window.console.warn('body:', body)
|
||||
console.error('body=', body)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case OP_AUTH_REPLY: {
|
||||
this.sendHeartbeat()
|
||||
break
|
||||
break
|
||||
}
|
||||
case OP_AUTH_REPLY: {
|
||||
// 认证响应
|
||||
body = JSON.parse(textDecoder.decode(body))
|
||||
if (body.code !== AUTH_REPLY_CODE_OK) {
|
||||
console.error('认证响应错误,body=', body)
|
||||
// 这里应该重新获取token再重连的,但前端没有用到token,所以不重新init了
|
||||
this.discardWebsocket()
|
||||
throw new Error('认证响应错误')
|
||||
}
|
||||
default: {
|
||||
let body = new Uint8Array(data.buffer, offset + HEADER_SIZE, packLen - HEADER_SIZE)
|
||||
window.console.warn('未知包类型:operation=', operation, body)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
offset += packLen
|
||||
this.sendHeartbeat()
|
||||
break
|
||||
}
|
||||
default: {
|
||||
// 未知消息
|
||||
console.warn('未知包类型,operation=', operation, dataView, body)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlerCommand (command) {
|
||||
if (command instanceof Array) {
|
||||
for (let oneCommand of command) {
|
||||
this.handlerCommand(oneCommand)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
handlerCommand(command) {
|
||||
let cmd = command.cmd || ''
|
||||
let pos = cmd.indexOf(':')
|
||||
if (pos != -1) {
|
||||
cmd = cmd.substr(0, pos)
|
||||
}
|
||||
let handler = COMMAND_HANDLERS[cmd]
|
||||
if (handler) {
|
||||
handler.call(this, command)
|
||||
let callback = CMD_CALLBACK_MAP[cmd]
|
||||
if (callback) {
|
||||
callback.call(this, command)
|
||||
}
|
||||
}
|
||||
|
||||
async onReceiveDanmaku (command) {
|
||||
async danmuMsgCallback(command) {
|
||||
if (!this.onAddText) {
|
||||
return
|
||||
}
|
||||
@@ -276,7 +322,6 @@ export default class ChatClientDirect {
|
||||
authorType = 0
|
||||
}
|
||||
|
||||
let urank = info[2][5]
|
||||
let data = {
|
||||
avatarUrl: await avatar.getAvatarUrl(uid),
|
||||
timestamp: info[0][4] / 1000,
|
||||
@@ -284,18 +329,19 @@ export default class ChatClientDirect {
|
||||
authorType: authorType,
|
||||
content: info[1],
|
||||
privilegeType: privilegeType,
|
||||
isGiftDanmaku: !!info[0][9],
|
||||
isGiftDanmaku: Boolean(info[0][9]),
|
||||
authorLevel: info[4][0],
|
||||
isNewbie: urank < 10000,
|
||||
isMobileVerified: !!info[2][6],
|
||||
isNewbie: info[2][5] < 10000,
|
||||
isMobileVerified: Boolean(info[2][6]),
|
||||
medalLevel: roomId === this.roomId ? medalLevel : 0,
|
||||
id: getUuid4Hex(),
|
||||
translation: ''
|
||||
translation: '',
|
||||
emoticon: info[0][13].url || null
|
||||
}
|
||||
this.onAddText(data)
|
||||
}
|
||||
|
||||
onReceiveGift (command) {
|
||||
sendGiftCallback(command) {
|
||||
if (!this.onAddGift) {
|
||||
return
|
||||
}
|
||||
@@ -316,7 +362,7 @@ export default class ChatClientDirect {
|
||||
this.onAddGift(data)
|
||||
}
|
||||
|
||||
async onBuyGuard (command) {
|
||||
async guardBuyCallback(command) {
|
||||
if (!this.onAddMember) {
|
||||
return
|
||||
}
|
||||
@@ -332,7 +378,7 @@ export default class ChatClientDirect {
|
||||
this.onAddMember(data)
|
||||
}
|
||||
|
||||
onSuperChat (command) {
|
||||
superChatMessageCallback(command) {
|
||||
if (!this.onAddSuperChat) {
|
||||
return
|
||||
}
|
||||
@@ -350,7 +396,7 @@ export default class ChatClientDirect {
|
||||
this.onAddSuperChat(data)
|
||||
}
|
||||
|
||||
onSuperChatDelete (command) {
|
||||
superChatMessageDeleteCallback(command) {
|
||||
if (!this.onDelSuperChat) {
|
||||
return
|
||||
}
|
||||
@@ -359,14 +405,14 @@ export default class ChatClientDirect {
|
||||
for (let id of command.data.ids) {
|
||||
ids.push(id.toString())
|
||||
}
|
||||
this.onDelSuperChat({ids})
|
||||
this.onDelSuperChat({ ids })
|
||||
}
|
||||
}
|
||||
|
||||
const COMMAND_HANDLERS = {
|
||||
DANMU_MSG: ChatClientDirect.prototype.onReceiveDanmaku,
|
||||
SEND_GIFT: ChatClientDirect.prototype.onReceiveGift,
|
||||
GUARD_BUY: ChatClientDirect.prototype.onBuyGuard,
|
||||
SUPER_CHAT_MESSAGE: ChatClientDirect.prototype.onSuperChat,
|
||||
SUPER_CHAT_MESSAGE_DELETE: ChatClientDirect.prototype.onSuperChatDelete
|
||||
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
|
||||
}
|
||||
@@ -7,11 +7,14 @@ const COMMAND_ADD_SUPER_CHAT = 5
|
||||
const COMMAND_DEL_SUPER_CHAT = 6
|
||||
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 = HEARTBEAT_INTERVAL + (5 * 1000)
|
||||
|
||||
export default class ChatClientRelay {
|
||||
constructor (roomId, autoTranslate) {
|
||||
constructor(roomId, autoTranslate) {
|
||||
this.roomId = roomId
|
||||
this.autoTranslate = autoTranslate
|
||||
|
||||
@@ -29,32 +32,30 @@ export default class ChatClientRelay {
|
||||
this.receiveTimeoutTimerId = null
|
||||
}
|
||||
|
||||
start () {
|
||||
start() {
|
||||
this.wsConnect()
|
||||
}
|
||||
|
||||
stop () {
|
||||
stop() {
|
||||
this.isDestroying = true
|
||||
if (this.websocket) {
|
||||
this.websocket.close()
|
||||
}
|
||||
}
|
||||
|
||||
wsConnect () {
|
||||
wsConnect() {
|
||||
if (this.isDestroying) {
|
||||
return
|
||||
}
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
// 开发时使用localhost:12450
|
||||
const host = process.env.NODE_ENV === 'development' ? 'localhost:12450' : window.location.host
|
||||
const url = `${protocol}://${host}/api/chat`
|
||||
const url = `${protocol}://${window.location.host}/api/chat`
|
||||
this.websocket = new WebSocket(url)
|
||||
this.websocket.onopen = this.onWsOpen.bind(this)
|
||||
this.websocket.onclose = this.onWsClose.bind(this)
|
||||
this.websocket.onmessage = this.onWsMessage.bind(this)
|
||||
}
|
||||
|
||||
onWsOpen () {
|
||||
onWsOpen() {
|
||||
this.retryCount = 0
|
||||
this.websocket.send(JSON.stringify({
|
||||
cmd: COMMAND_JOIN_ROOM,
|
||||
@@ -69,7 +70,7 @@ export default class ChatClientRelay {
|
||||
this.refreshReceiveTimeoutTimer()
|
||||
}
|
||||
|
||||
sendHeartbeat () {
|
||||
sendHeartbeat() {
|
||||
this.websocket.send(JSON.stringify({
|
||||
cmd: COMMAND_HEARTBEAT
|
||||
}))
|
||||
@@ -83,7 +84,7 @@ export default class ChatClientRelay {
|
||||
}
|
||||
|
||||
onReceiveTimeout() {
|
||||
window.console.warn('接收消息超时')
|
||||
console.warn('接收消息超时')
|
||||
this.receiveTimeoutTimerId = null
|
||||
|
||||
// 直接丢弃阻塞的websocket,不等onclose回调了
|
||||
@@ -92,7 +93,7 @@ export default class ChatClientRelay {
|
||||
this.onWsClose()
|
||||
}
|
||||
|
||||
onWsClose () {
|
||||
onWsClose() {
|
||||
this.websocket = null
|
||||
if (this.heartbeatTimerId) {
|
||||
window.clearInterval(this.heartbeatTimerId)
|
||||
@@ -106,14 +107,14 @@ export default class ChatClientRelay {
|
||||
if (this.isDestroying) {
|
||||
return
|
||||
}
|
||||
window.console.warn(`掉线重连中${++this.retryCount}`)
|
||||
console.warn(`掉线重连中${++this.retryCount}`)
|
||||
window.setTimeout(this.wsConnect.bind(this), 1000)
|
||||
}
|
||||
|
||||
onWsMessage (event) {
|
||||
onWsMessage(event) {
|
||||
this.refreshReceiveTimeoutTimer()
|
||||
|
||||
let {cmd, data} = JSON.parse(event.data)
|
||||
let { cmd, data } = JSON.parse(event.data)
|
||||
switch (cmd) {
|
||||
case COMMAND_HEARTBEAT: {
|
||||
break
|
||||
@@ -122,6 +123,14 @@ export default class ChatClientRelay {
|
||||
if (!this.onAddText) {
|
||||
break
|
||||
}
|
||||
|
||||
let emoticon = null
|
||||
let contentType = data[13]
|
||||
let contentTypeParams = data[14]
|
||||
if (contentType === CONTENT_TYPE_EMOTICON) {
|
||||
emoticon = contentTypeParams[0]
|
||||
}
|
||||
|
||||
data = {
|
||||
avatarUrl: data[0],
|
||||
timestamp: data[1],
|
||||
@@ -129,13 +138,14 @@ export default class ChatClientRelay {
|
||||
authorType: data[3],
|
||||
content: data[4],
|
||||
privilegeType: data[5],
|
||||
isGiftDanmaku: !!data[6],
|
||||
isGiftDanmaku: Boolean(data[6]),
|
||||
authorLevel: data[7],
|
||||
isNewbie: !!data[8],
|
||||
isMobileVerified: !!data[9],
|
||||
isNewbie: Boolean(data[8]),
|
||||
isMobileVerified: Boolean(data[9]),
|
||||
medalLevel: data[10],
|
||||
id: data[11],
|
||||
translation: data[12]
|
||||
translation: data[12],
|
||||
emoticon: emoticon
|
||||
}
|
||||
this.onAddText(data)
|
||||
break
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {getUuid4Hex} from '@/utils'
|
||||
import { getUuid4Hex } from '@/utils'
|
||||
import * as constants from '@/components/ChatRenderer/constants'
|
||||
import * as avatar from './avatar'
|
||||
|
||||
@@ -19,31 +19,39 @@ const CONTENTS = [
|
||||
'有一说一,这件事大家懂的都懂,不懂的,说了你也不明白,不如不说', '让我看看', '我柜子动了,我不玩了'
|
||||
]
|
||||
|
||||
const AUTHOR_TYPES = [
|
||||
{weight: 10, value: constants.AUTHRO_TYPE_NORMAL},
|
||||
{weight: 5, value: constants.AUTHRO_TYPE_MEMBER},
|
||||
{weight: 2, value: constants.AUTHRO_TYPE_ADMIN},
|
||||
{weight: 1, value: constants.AUTHRO_TYPE_OWNER}
|
||||
const EMOTICONS = [
|
||||
'/static/img/emoticons/233.png',
|
||||
'/static/img/emoticons/miaoa.png',
|
||||
'/static/img/emoticons/lipu.png'
|
||||
]
|
||||
|
||||
function randGuardInfo () {
|
||||
const AUTHOR_TYPES = [
|
||||
{ weight: 10, value: constants.AUTHRO_TYPE_NORMAL },
|
||||
{ weight: 5, value: constants.AUTHRO_TYPE_MEMBER },
|
||||
{ weight: 2, value: constants.AUTHRO_TYPE_ADMIN },
|
||||
{ weight: 1, value: constants.AUTHRO_TYPE_OWNER }
|
||||
]
|
||||
|
||||
function randGuardInfo() {
|
||||
let authorType = randomChoose(AUTHOR_TYPES)
|
||||
let privilegeType
|
||||
if (authorType === constants.AUTHRO_TYPE_MEMBER || authorType === constants.AUTHRO_TYPE_ADMIN) {
|
||||
if (authorType === constants.AUTHRO_TYPE_MEMBER) {
|
||||
privilegeType = randInt(1, 3)
|
||||
} else if (authorType === constants.AUTHRO_TYPE_ADMIN) {
|
||||
privilegeType = randInt(0, 3)
|
||||
} else {
|
||||
privilegeType = 0
|
||||
}
|
||||
return {authorType, privilegeType}
|
||||
return { authorType, privilegeType }
|
||||
}
|
||||
|
||||
const GIFT_INFO_LIST = [
|
||||
{giftName: 'B坷垃', totalCoin: 9900},
|
||||
{giftName: '礼花', totalCoin: 28000},
|
||||
{giftName: '花式夸夸', totalCoin: 39000},
|
||||
{giftName: '天空之翼', totalCoin: 100000},
|
||||
{giftName: '摩天大楼', totalCoin: 450000},
|
||||
{giftName: '小电视飞船', totalCoin: 1245000}
|
||||
{ giftName: 'B坷垃', totalCoin: 9900 },
|
||||
{ giftName: '礼花', totalCoin: 28000 },
|
||||
{ giftName: '花式夸夸', totalCoin: 39000 },
|
||||
{ giftName: '天空之翼', totalCoin: 100000 },
|
||||
{ giftName: '摩天大楼', totalCoin: 450000 },
|
||||
{ giftName: '小电视飞船', totalCoin: 1245000 }
|
||||
]
|
||||
|
||||
const SC_PRICES = [
|
||||
@@ -65,11 +73,36 @@ const MESSAGE_GENERATORS = [
|
||||
content: randomChoose(CONTENTS),
|
||||
isGiftDanmaku: randInt(1, 10) <= 1,
|
||||
authorLevel: randInt(0, 60),
|
||||
isNewbie: randInt(1, 10) <= 9,
|
||||
isNewbie: randInt(1, 10) <= 1,
|
||||
isMobileVerified: randInt(1, 10) <= 9,
|
||||
medalLevel: randInt(0, 40),
|
||||
id: getUuid4Hex(),
|
||||
translation: ''
|
||||
translation: '',
|
||||
emoticon: null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 表情
|
||||
{
|
||||
weight: 5,
|
||||
value() {
|
||||
return {
|
||||
type: constants.MESSAGE_TYPE_TEXT,
|
||||
message: {
|
||||
...randGuardInfo(),
|
||||
avatarUrl: avatar.DEFAULT_AVATAR_URL,
|
||||
timestamp: new Date().getTime() / 1000,
|
||||
authorName: randomChoose(NAMES),
|
||||
content: '',
|
||||
isGiftDanmaku: false,
|
||||
authorLevel: randInt(0, 60),
|
||||
isNewbie: randInt(1, 10) <= 1,
|
||||
isMobileVerified: randInt(1, 10) <= 9,
|
||||
medalLevel: randInt(0, 40),
|
||||
id: getUuid4Hex(),
|
||||
translation: '',
|
||||
emoticon: randomChoose(EMOTICONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +160,7 @@ const MESSAGE_GENERATORS = [
|
||||
}
|
||||
]
|
||||
|
||||
function randomChoose (nodes) {
|
||||
function randomChoose(nodes) {
|
||||
if (nodes.length === 0) {
|
||||
return null
|
||||
}
|
||||
@@ -155,12 +188,12 @@ function randomChoose (nodes) {
|
||||
return null
|
||||
}
|
||||
|
||||
function randInt (min, max) {
|
||||
return Math.floor(min + (max - min + 1) * Math.random())
|
||||
function randInt(min, max) {
|
||||
return Math.floor(min + ((max - min + 1) * Math.random()))
|
||||
}
|
||||
|
||||
export default class ChatClientTest {
|
||||
constructor () {
|
||||
constructor() {
|
||||
this.minSleepTime = 800
|
||||
this.maxSleepTime = 1200
|
||||
|
||||
@@ -174,25 +207,25 @@ export default class ChatClientTest {
|
||||
this.timerId = null
|
||||
}
|
||||
|
||||
start () {
|
||||
start() {
|
||||
this.refreshTimer()
|
||||
}
|
||||
|
||||
stop () {
|
||||
stop() {
|
||||
if (this.timerId) {
|
||||
window.clearTimeout(this.timerId)
|
||||
this.timerId = null
|
||||
}
|
||||
}
|
||||
|
||||
refreshTimer () {
|
||||
refreshTimer() {
|
||||
this.timerId = window.setTimeout(this.onTimeout.bind(this), randInt(this.minSleepTime, this.maxSleepTime))
|
||||
}
|
||||
|
||||
onTimeout () {
|
||||
onTimeout() {
|
||||
this.refreshTimer()
|
||||
|
||||
let {type, message} = randomChoose(MESSAGE_GENERATORS)()
|
||||
let { type, message } = randomChoose(MESSAGE_GENERATORS)()
|
||||
switch (type) {
|
||||
case constants.MESSAGE_TYPE_TEXT:
|
||||
this.onAddText(message)
|
||||
|
||||
@@ -2,7 +2,7 @@ import axios from 'axios'
|
||||
|
||||
export const DEFAULT_AVATAR_URL = '//static.hdslb.com/images/member/noface.gif'
|
||||
|
||||
export function processAvatarUrl (avatarUrl) {
|
||||
export function processAvatarUrl(avatarUrl) {
|
||||
// 去掉协议,兼容HTTP、HTTPS
|
||||
let m = avatarUrl.match(/(?:https?:)?(.*)/)
|
||||
if (m) {
|
||||
@@ -15,12 +15,12 @@ export function processAvatarUrl (avatarUrl) {
|
||||
return avatarUrl
|
||||
}
|
||||
|
||||
export async function getAvatarUrl (uid) {
|
||||
export async function getAvatarUrl(uid) {
|
||||
let res
|
||||
try {
|
||||
res = (await axios.get('/api/avatar_url', {params: {
|
||||
res = (await axios.get('/api/avatar_url', { params: {
|
||||
uid: uid
|
||||
}})).data
|
||||
} })).data
|
||||
} catch {
|
||||
return DEFAULT_AVATAR_URL
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {mergeConfig} from '@/utils'
|
||||
import _ from 'lodash'
|
||||
|
||||
import { mergeConfig } from '@/utils'
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
minGiftPrice: 7, // $1
|
||||
@@ -19,18 +21,48 @@ export const DEFAULT_CONFIG = {
|
||||
|
||||
relayMessagesByServer: false,
|
||||
autoTranslate: false,
|
||||
giftUsernamePronunciation: ''
|
||||
giftUsernamePronunciation: '',
|
||||
|
||||
emoticons: [] // [{ keyword: '', url: '' }, ...]
|
||||
}
|
||||
|
||||
export function setLocalConfig (config) {
|
||||
export function deepCloneDefaultConfig() {
|
||||
return _.cloneDeep(DEFAULT_CONFIG)
|
||||
}
|
||||
|
||||
export function setLocalConfig(config) {
|
||||
config = mergeConfig(config, DEFAULT_CONFIG)
|
||||
window.localStorage.config = JSON.stringify(config)
|
||||
}
|
||||
|
||||
export function getLocalConfig () {
|
||||
export function getLocalConfig() {
|
||||
try {
|
||||
return mergeConfig(JSON.parse(window.localStorage.config), DEFAULT_CONFIG)
|
||||
let config = JSON.parse(window.localStorage.config)
|
||||
config = mergeConfig(config, deepCloneDefaultConfig())
|
||||
sanitizeConfig(config)
|
||||
return config
|
||||
} catch {
|
||||
return {...DEFAULT_CONFIG}
|
||||
return deepCloneDefaultConfig()
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeConfig(config) {
|
||||
let newEmoticons = []
|
||||
if (config.emoticons instanceof Array) {
|
||||
for (let emoticon of config.emoticons) {
|
||||
try {
|
||||
let newEmoticon = {
|
||||
keyword: emoticon.keyword,
|
||||
url: emoticon.url
|
||||
}
|
||||
if ((typeof newEmoticon.keyword !== 'string') || (typeof newEmoticon.url !== 'string')) {
|
||||
continue
|
||||
}
|
||||
newEmoticons.push(newEmoticon)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
config.emoticons = newEmoticons
|
||||
}
|
||||
|
||||
11
frontend/src/api/main.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export async function getServerInfo() {
|
||||
return (await axios.get('/api/server_info')).data
|
||||
}
|
||||
|
||||
export async function uploadEmoticon(file) {
|
||||
let body = new FormData()
|
||||
body.set('file', file)
|
||||
return (await axios.post('/api/emoticon', body)).data
|
||||
}
|
||||
@@ -314,7 +314,9 @@ yt-live-chat-membership-item-renderer[dashboard-money-feed] #content.yt-live-cha
|
||||
}
|
||||
|
||||
#content.yt-live-chat-membership-item-renderer img.yt-live-chat-membership-item-renderer {
|
||||
width: var(--yt-live-chat-emoji-size);
|
||||
/* B站表情有不是正方形的 */
|
||||
/* width: var(--yt-live-chat-emoji-size); */
|
||||
width: auto;
|
||||
height: var(--yt-live-chat-emoji-size);
|
||||
|
||||
margin: -1px 2px 1px 2px;
|
||||
|
||||
@@ -312,7 +312,9 @@ yt-live-chat-paid-message-renderer[allow-animations] #content.yt-live-chat-paid-
|
||||
}
|
||||
|
||||
#content.yt-live-chat-paid-message-renderer img.yt-live-chat-paid-message-renderer {
|
||||
width: var(--yt-live-chat-emoji-size);
|
||||
/* B站表情有不是正方形的 */
|
||||
/* width: var(--yt-live-chat-emoji-size); */
|
||||
width: auto;
|
||||
height: var(--yt-live-chat-emoji-size);
|
||||
margin: -1px 2px 1px 2px;
|
||||
vertical-align: middle;
|
||||
|
||||
@@ -159,7 +159,9 @@ yt-live-chat-author-chip.yt-live-chat-text-message-renderer {
|
||||
}
|
||||
|
||||
#message.yt-live-chat-text-message-renderer .emoji.yt-live-chat-text-message-renderer {
|
||||
width: var(--yt-live-chat-emoji-size);
|
||||
/* B站表情有不是正方形的 */
|
||||
/* width: var(--yt-live-chat-emoji-size); */
|
||||
width: auto;
|
||||
height: var(--yt-live-chat-emoji-size);
|
||||
margin: -1px 2px 1px 2px;
|
||||
vertical-align: middle;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<yt-live-chat-author-badge-renderer :type="authorTypeText" v-if="isAdmin || privilegeType > 0">
|
||||
<yt-live-chat-author-badge-renderer :type="authorTypeText">
|
||||
<el-tooltip :content="readableAuthorTypeText" placement="top">
|
||||
<div id="image" class="style-scope yt-live-chat-author-badge-renderer">
|
||||
<yt-icon v-if="isAdmin" class="style-scope yt-live-chat-author-badge-renderer">
|
||||
@@ -14,7 +14,8 @@
|
||||
</svg>
|
||||
</yt-icon>
|
||||
<img v-else :src="`/static/img/icons/guard-level-${privilegeType}.png`"
|
||||
class="style-scope yt-live-chat-author-badge-renderer" :alt="readableAuthorTypeText">
|
||||
class="style-scope yt-live-chat-author-badge-renderer" :alt="readableAuthorTypeText"
|
||||
>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</yt-live-chat-author-badge-renderer>
|
||||
@@ -38,9 +39,9 @@ export default {
|
||||
},
|
||||
readableAuthorTypeText() {
|
||||
if (this.isAdmin) {
|
||||
return '管理员'
|
||||
return this.$t('chat.moderator')
|
||||
}
|
||||
return constants.GUARD_LEVEL_TO_TEXT[this.privilegeType]
|
||||
return constants.getShowGuardLevelText(this.privilegeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
54
frontend/src/components/ChatRenderer/AuthorChip.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<yt-live-chat-author-chip>
|
||||
<span id="author-name" dir="auto" class="style-scope yt-live-chat-author-chip" :class="{ member: isInMemberMessage }"
|
||||
:type="authorTypeText"
|
||||
>
|
||||
<template>{{ authorName }}</template>
|
||||
<!-- 这里是已验证勋章 -->
|
||||
<span id="chip-badges" class="style-scope yt-live-chat-author-chip"></span>
|
||||
</span>
|
||||
<span id="chat-badges" class="style-scope yt-live-chat-author-chip">
|
||||
<author-badge v-if="isInMemberMessage" class="style-scope yt-live-chat-author-chip"
|
||||
:isAdmin="false" :privilegeType="privilegeType"
|
||||
></author-badge>
|
||||
<template v-else>
|
||||
<author-badge v-if="authorType === AUTHRO_TYPE_ADMIN" class="style-scope yt-live-chat-author-chip"
|
||||
isAdmin :privilegeType="0"
|
||||
></author-badge>
|
||||
<author-badge v-if="privilegeType > 0" class="style-scope yt-live-chat-author-chip"
|
||||
:isAdmin="false" :privilegeType="privilegeType"
|
||||
></author-badge>
|
||||
</template>
|
||||
</span>
|
||||
</yt-live-chat-author-chip>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AuthorBadge from './AuthorBadge'
|
||||
import * as constants from './constants'
|
||||
|
||||
export default {
|
||||
name: 'AuthorChip',
|
||||
components: {
|
||||
AuthorBadge
|
||||
},
|
||||
props: {
|
||||
isInMemberMessage: Boolean,
|
||||
authorName: String,
|
||||
authorType: Number,
|
||||
privilegeType: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
AUTHRO_TYPE_ADMIN: constants.AUTHRO_TYPE_ADMIN
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
authorTypeText() {
|
||||
return constants.AUTHOR_TYPE_TO_TEXT[this.authorType]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style src="@/assets/css/youtube/yt-live-chat-author-chip.css"></style>
|
||||
@@ -8,22 +8,13 @@
|
||||
<div id="header-content" class="style-scope yt-live-chat-membership-item-renderer">
|
||||
<div id="header-content-primary-column" class="style-scope yt-live-chat-membership-item-renderer">
|
||||
<div id="header-content-inner-column" class="style-scope yt-live-chat-membership-item-renderer">
|
||||
<yt-live-chat-author-chip class="style-scope yt-live-chat-membership-item-renderer">
|
||||
<span id="author-name" dir="auto" class="member style-scope yt-live-chat-author-chip">{{
|
||||
authorName
|
||||
}}<!-- 这里是已验证勋章 -->
|
||||
<span id="chip-badges" class="style-scope yt-live-chat-author-chip"></span>
|
||||
</span>
|
||||
<span id="chat-badges" class="style-scope yt-live-chat-author-chip">
|
||||
<author-badge class="style-scope yt-live-chat-author-chip"
|
||||
:isAdmin="false" :privilegeType="privilegeType"
|
||||
></author-badge>
|
||||
</span>
|
||||
</yt-live-chat-author-chip>
|
||||
<author-chip class="style-scope yt-live-chat-membership-item-renderer"
|
||||
isInMemberMessage :authorName="authorName" :authorType="0" :privilegeType="privilegeType"
|
||||
></author-chip>
|
||||
</div>
|
||||
<div id="header-subtext" class="style-scope yt-live-chat-membership-item-renderer">{{title}}</div>
|
||||
<div id="header-subtext" class="style-scope yt-live-chat-membership-item-renderer">{{ title }}</div>
|
||||
</div>
|
||||
<div id="timestamp" class="style-scope yt-live-chat-membership-item-renderer">{{timeText}}</div>
|
||||
<div id="timestamp" class="style-scope yt-live-chat-membership-item-renderer">{{ timeText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,15 +22,15 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ImgShadow from './ImgShadow.vue'
|
||||
import AuthorBadge from './AuthorBadge.vue'
|
||||
import ImgShadow from './ImgShadow'
|
||||
import AuthorChip from './AuthorChip'
|
||||
import * as utils from '@/utils'
|
||||
|
||||
export default {
|
||||
name: 'MembershipItem',
|
||||
components: {
|
||||
ImgShadow,
|
||||
AuthorBadge
|
||||
AuthorChip
|
||||
},
|
||||
props: {
|
||||
avatarUrl: String,
|
||||
|
||||
@@ -16,23 +16,21 @@
|
||||
></img-shadow>
|
||||
<div id="header-content" class="style-scope yt-live-chat-paid-message-renderer">
|
||||
<div id="header-content-primary-column" class="style-scope yt-live-chat-paid-message-renderer">
|
||||
<div id="author-name" class="style-scope yt-live-chat-paid-message-renderer">{{authorName}}</div>
|
||||
<div id="purchase-amount" class="style-scope yt-live-chat-paid-message-renderer">{{priceText}}</div>
|
||||
<div id="author-name" class="style-scope yt-live-chat-paid-message-renderer">{{ authorName }}</div>
|
||||
<div id="purchase-amount" class="style-scope yt-live-chat-paid-message-renderer">{{ priceText }}</div>
|
||||
</div>
|
||||
<span id="timestamp" class="style-scope yt-live-chat-paid-message-renderer">{{timeText}}</span>
|
||||
<span id="timestamp" class="style-scope yt-live-chat-paid-message-renderer">{{ timeText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="content" class="style-scope yt-live-chat-paid-message-renderer">
|
||||
<div id="message" dir="auto" class="style-scope yt-live-chat-paid-message-renderer">{{
|
||||
content
|
||||
}}</div>
|
||||
<div id="message" dir="auto" class="style-scope yt-live-chat-paid-message-renderer">{{ content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</yt-live-chat-paid-message-renderer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ImgShadow from './ImgShadow.vue'
|
||||
import ImgShadow from './ImgShadow'
|
||||
import * as constants from './constants'
|
||||
import * as utils from '@/utils'
|
||||
|
||||
@@ -53,7 +51,7 @@ export default {
|
||||
return constants.getPriceConfig(this.price).colors
|
||||
},
|
||||
priceText() {
|
||||
return 'CN¥' + utils.formatCurrency(this.price)
|
||||
return `CN¥${utils.formatCurrency(this.price)}`
|
||||
},
|
||||
timeText() {
|
||||
return utils.getTimeTextHourMin(this.time)
|
||||
|
||||
@@ -4,23 +4,20 @@
|
||||
:imgUrl="avatarUrl"
|
||||
></img-shadow>
|
||||
<div id="content" class="style-scope yt-live-chat-text-message-renderer">
|
||||
<span id="timestamp" class="style-scope yt-live-chat-text-message-renderer">{{timeText}}</span>
|
||||
<yt-live-chat-author-chip class="style-scope yt-live-chat-text-message-renderer">
|
||||
<span id="author-name" dir="auto" class="style-scope yt-live-chat-author-chip" :type="authorTypeText">{{
|
||||
authorName
|
||||
}}<!-- 这里是已验证勋章 -->
|
||||
<span id="chip-badges" class="style-scope yt-live-chat-author-chip"></span>
|
||||
</span>
|
||||
<span id="chat-badges" class="style-scope yt-live-chat-author-chip">
|
||||
<author-badge class="style-scope yt-live-chat-author-chip"
|
||||
:isAdmin="authorType === 2" :privilegeType="privilegeType"
|
||||
></author-badge>
|
||||
</span>
|
||||
</yt-live-chat-author-chip>
|
||||
<span id="message" class="style-scope yt-live-chat-text-message-renderer">{{
|
||||
content
|
||||
}}<el-badge :value="repeated" :max="99" v-show="repeated > 1" class="style-scope yt-live-chat-text-message-renderer"
|
||||
:style="{'--repeated-mark-color': repeatedMarkColor}"
|
||||
<span id="timestamp" class="style-scope yt-live-chat-text-message-renderer">{{ timeText }}</span>
|
||||
<author-chip class="style-scope yt-live-chat-text-message-renderer"
|
||||
:isInMemberMessage="false" :authorName="authorName" :authorType="authorType" :privilegeType="privilegeType"
|
||||
></author-chip>
|
||||
<span id="message" class="style-scope yt-live-chat-text-message-renderer">
|
||||
<template v-for="(content, index) in richContent">
|
||||
<span :key="index" v-if="content.type === CONTENT_TYPE_TEXT">{{ content.text }}</span>
|
||||
<img :key="index" v-else-if="content.type === CONTENT_TYPE_IMAGE"
|
||||
class="emoji yt-formatted-string style-scope yt-live-chat-text-message-renderer"
|
||||
:src="content.url" :alt="content.text" :shared-tooltip-text="content.text" :id="`emoji-${content.text}`"
|
||||
>
|
||||
</template>
|
||||
<el-badge :value="repeated" :max="99" v-if="repeated > 1" class="style-scope yt-live-chat-text-message-renderer"
|
||||
:style="{ '--repeated-mark-color': repeatedMarkColor }"
|
||||
></el-badge>
|
||||
</span>
|
||||
</div>
|
||||
@@ -28,8 +25,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ImgShadow from './ImgShadow.vue'
|
||||
import AuthorBadge from './AuthorBadge.vue'
|
||||
import ImgShadow from './ImgShadow'
|
||||
import AuthorChip from './AuthorChip'
|
||||
import * as constants from './constants'
|
||||
import * as utils from '@/utils'
|
||||
|
||||
@@ -41,17 +38,23 @@ export default {
|
||||
name: 'TextMessage',
|
||||
components: {
|
||||
ImgShadow,
|
||||
AuthorBadge
|
||||
AuthorChip
|
||||
},
|
||||
props: {
|
||||
avatarUrl: String,
|
||||
time: Date,
|
||||
authorName: String,
|
||||
authorType: Number,
|
||||
content: String,
|
||||
richContent: Array,
|
||||
privilegeType: Number,
|
||||
repeated: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
CONTENT_TYPE_TEXT: constants.CONTENT_TYPE_TEXT,
|
||||
CONTENT_TYPE_IMAGE: constants.CONTENT_TYPE_IMAGE
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
timeText() {
|
||||
return utils.getTimeTextHourMin(this.time)
|
||||
@@ -69,7 +72,7 @@ export default {
|
||||
color = [0, 0, 0]
|
||||
let t = (this.repeated - 2) / (10 - 2)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
color[i] = REPEATED_MARK_COLOR_START[i] + (REPEATED_MARK_COLOR_END[i] - REPEATED_MARK_COLOR_START[i]) * t
|
||||
color[i] = REPEATED_MARK_COLOR_START[i] + ((REPEATED_MARK_COLOR_END[i] - REPEATED_MARK_COLOR_START[i]) * t)
|
||||
}
|
||||
}
|
||||
return `hsl(${color[0]}, ${color[1]}%, ${color[2]}%)`
|
||||
@@ -95,4 +98,3 @@ yt-live-chat-text-message-renderer>#content>#message>.el-badge .el-badge__conten
|
||||
</style>
|
||||
|
||||
<style src="@/assets/css/youtube/yt-live-chat-text-message-renderer.css"></style>
|
||||
<style src="@/assets/css/youtube/yt-live-chat-author-chip.css"></style>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<img-shadow id="author-photo" height="24" width="24" class="style-scope yt-live-chat-ticker-paid-message-item-renderer"
|
||||
:imgUrl="message.raw.avatarUrl"
|
||||
></img-shadow>
|
||||
<span id="text" dir="ltr" class="style-scope yt-live-chat-ticker-paid-message-item-renderer">{{message.text}}</span>
|
||||
<span id="text" dir="ltr" class="style-scope yt-live-chat-ticker-paid-message-item-renderer">{{ message.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</yt-live-chat-ticker-paid-message-item-renderer>
|
||||
@@ -40,10 +40,10 @@
|
||||
|
||||
<script>
|
||||
import * as chatConfig from '@/api/chatConfig'
|
||||
import {formatCurrency} from '@/utils'
|
||||
import ImgShadow from './ImgShadow.vue'
|
||||
import MembershipItem from './MembershipItem.vue'
|
||||
import PaidMessage from './PaidMessage.vue'
|
||||
import { formatCurrency } from '@/utils'
|
||||
import ImgShadow from './ImgShadow'
|
||||
import MembershipItem from './MembershipItem'
|
||||
import PaidMessage from './PaidMessage'
|
||||
import * as constants from './constants'
|
||||
|
||||
export default {
|
||||
@@ -142,7 +142,7 @@ export default {
|
||||
color2 = config.colors.headerBg
|
||||
}
|
||||
let pinTime = this.getPinTime(message)
|
||||
let progress = (1 - (this.curTime - message.addTime) / (60 * 1000) / pinTime) * 100
|
||||
let progress = (1 - ((this.curTime - message.addTime) / (60 * 1000) / pinTime)) * 100
|
||||
if (progress < 0) {
|
||||
progress = 0
|
||||
} else if (progress > 100) {
|
||||
@@ -158,9 +158,9 @@ export default {
|
||||
},
|
||||
getText(message) {
|
||||
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
|
||||
return 'Member'
|
||||
return this.$t('chat.tickerMembership')
|
||||
}
|
||||
return 'CN¥' + formatCurrency(message.price)
|
||||
return `CN¥${formatCurrency(message.price)}`
|
||||
},
|
||||
getPinTime(message) {
|
||||
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
|
||||
@@ -169,18 +169,25 @@ export default {
|
||||
return constants.getPriceConfig(message.price).pinTime
|
||||
},
|
||||
updateProgress() {
|
||||
// 更新进度
|
||||
this.curTime = new Date()
|
||||
for (let i = 0; i < this.messages.length;) {
|
||||
let message = this.messages[i]
|
||||
|
||||
// 删除过期的消息
|
||||
let filteredMessages = []
|
||||
let messagesChanged = false
|
||||
for (let message of this.messages) {
|
||||
let pinTime = this.getPinTime(message)
|
||||
if ((this.curTime - message.addTime) / (60 * 1000) >= pinTime) {
|
||||
if (this.pinnedMessage == message) {
|
||||
messagesChanged = true
|
||||
if (this.pinnedMessage === message) {
|
||||
this.pinnedMessage = null
|
||||
}
|
||||
this.messages.splice(i, 1)
|
||||
} else {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
filteredMessages.push(message)
|
||||
}
|
||||
if (messagesChanged) {
|
||||
this.$emit('update:messages', filteredMessages)
|
||||
}
|
||||
},
|
||||
onItemClick(message) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as i18n from '@/i18n'
|
||||
|
||||
export const AUTHRO_TYPE_NORMAL = 0
|
||||
export const AUTHRO_TYPE_MEMBER = 1
|
||||
export const AUTHRO_TYPE_ADMIN = 2
|
||||
@@ -10,13 +12,21 @@ export const AUTHOR_TYPE_TO_TEXT = [
|
||||
'owner' // 主播
|
||||
]
|
||||
|
||||
export const GUARD_LEVEL_TO_TEXT = [
|
||||
const GUARD_LEVEL_TO_TEXT_KEY = [
|
||||
'',
|
||||
'总督',
|
||||
'提督',
|
||||
'舰长'
|
||||
'chat.guardLevel1',
|
||||
'chat.guardLevel2',
|
||||
'chat.guardLevel3'
|
||||
]
|
||||
|
||||
export function getShowGuardLevelText(guardLevel) {
|
||||
let key = GUARD_LEVEL_TO_TEXT_KEY[guardLevel] || ''
|
||||
if (key === '') {
|
||||
return ''
|
||||
}
|
||||
return i18n.i18n.t(key)
|
||||
}
|
||||
|
||||
export const MESSAGE_TYPE_TEXT = 0
|
||||
export const MESSAGE_TYPE_GIFT = 1
|
||||
export const MESSAGE_TYPE_MEMBER = 2
|
||||
@@ -24,6 +34,9 @@ export const MESSAGE_TYPE_SUPER_CHAT = 3
|
||||
export const MESSAGE_TYPE_DEL = 4
|
||||
export const MESSAGE_TYPE_UPDATE = 5
|
||||
|
||||
export const CONTENT_TYPE_TEXT = 0
|
||||
export const CONTENT_TYPE_IMAGE = 1
|
||||
|
||||
// 美元 -> 人民币 汇率
|
||||
const EXCHANGE_RATE = 7
|
||||
export const PRICE_CONFIGS = [
|
||||
@@ -100,7 +113,7 @@ export const PRICE_CONFIGS = [
|
||||
pinTime: 0
|
||||
},
|
||||
{ // $1蓝
|
||||
price: 1 * EXCHANGE_RATE,
|
||||
price: EXCHANGE_RATE,
|
||||
colors: {
|
||||
contentBg: 'rgba(30,136,229,1)',
|
||||
headerBg: 'rgba(21,101,192,1)',
|
||||
@@ -113,7 +126,7 @@ export const PRICE_CONFIGS = [
|
||||
}
|
||||
]
|
||||
|
||||
export function getPriceConfig (price) {
|
||||
export function getPriceConfig(price) {
|
||||
for (const config of PRICE_CONFIGS) {
|
||||
if (price >= config.price) {
|
||||
return config
|
||||
@@ -122,21 +135,32 @@ export function getPriceConfig (price) {
|
||||
return PRICE_CONFIGS[PRICE_CONFIGS.length - 1]
|
||||
}
|
||||
|
||||
export function getShowContent (message) {
|
||||
export function getShowContent(message) {
|
||||
if (message.translation) {
|
||||
return `${message.content}(${message.translation})`
|
||||
}
|
||||
return message.content
|
||||
}
|
||||
|
||||
export function getGiftShowContent (message, showGiftName) {
|
||||
export function getShowRichContent(message) {
|
||||
let richContent = [...message.richContent]
|
||||
if (message.translation) {
|
||||
richContent.push({
|
||||
type: CONTENT_TYPE_TEXT,
|
||||
text: `(${message.translation})`
|
||||
})
|
||||
}
|
||||
return richContent
|
||||
}
|
||||
|
||||
export function getGiftShowContent(message, showGiftName) {
|
||||
if (!showGiftName) {
|
||||
return ''
|
||||
}
|
||||
return `Sent ${message.giftName}x${message.num}`
|
||||
return i18n.i18n.t('chat.sendGift', { giftName: message.giftName, num: message.num })
|
||||
}
|
||||
|
||||
export function getShowAuthorName (message) {
|
||||
export function getShowAuthorName(message) {
|
||||
if (message.authorNamePronunciation && message.authorNamePronunciation !== message.authorName) {
|
||||
return `${message.authorName}(${message.authorNamePronunciation})`
|
||||
}
|
||||
|
||||
@@ -2,34 +2,47 @@
|
||||
<yt-live-chat-renderer class="style-scope yt-live-chat-app" style="--scrollbar-width:11px;" hide-timestamps
|
||||
@mousemove="refreshCantScrollStartTime"
|
||||
>
|
||||
<ticker class="style-scope yt-live-chat-renderer" :messages="paidMessages" :showGiftName="showGiftName"></ticker>
|
||||
<ticker class="style-scope yt-live-chat-renderer" :messages.sync="paidMessages" :showGiftName="showGiftName"></ticker>
|
||||
<yt-live-chat-item-list-renderer class="style-scope yt-live-chat-renderer" allow-scroll>
|
||||
<div ref="scroller" id="item-scroller" class="style-scope yt-live-chat-item-list-renderer animated" @scroll="onScroll">
|
||||
<div ref="itemOffset" id="item-offset" class="style-scope yt-live-chat-item-list-renderer" style="height: 0px;">
|
||||
<div ref="items" id="items" class="style-scope yt-live-chat-item-list-renderer" style="overflow: hidden"
|
||||
:style="{transform: `translateY(${Math.floor(scrollPixelsRemaining)}px)`}"
|
||||
:style="{ transform: `translateY(${Math.floor(scrollPixelsRemaining)}px)` }"
|
||||
>
|
||||
<template v-for="message in messages">
|
||||
<text-message :key="message.id" v-if="message.type === MESSAGE_TYPE_TEXT"
|
||||
class="style-scope yt-live-chat-item-list-renderer"
|
||||
:avatarUrl="message.avatarUrl" :time="message.time" :authorName="message.authorName"
|
||||
:authorType="message.authorType" :content="getShowContent(message)" :privilegeType="message.privilegeType"
|
||||
:time="message.time"
|
||||
:avatarUrl="message.avatarUrl"
|
||||
:authorName="message.authorName"
|
||||
:authorType="message.authorType"
|
||||
:privilegeType="message.privilegeType"
|
||||
:richContent="getShowRichContent(message)"
|
||||
:repeated="message.repeated"
|
||||
></text-message>
|
||||
<paid-message :key="message.id" v-else-if="message.type === MESSAGE_TYPE_GIFT"
|
||||
class="style-scope yt-live-chat-item-list-renderer"
|
||||
:price="message.price" :avatarUrl="message.avatarUrl" :authorName="getShowAuthorName(message)"
|
||||
:time="message.time" :content="getGiftShowContent(message)"
|
||||
:time="message.time"
|
||||
:avatarUrl="message.avatarUrl"
|
||||
:authorName="getShowAuthorName(message)"
|
||||
:price="message.price"
|
||||
:content="getGiftShowContent(message)"
|
||||
></paid-message>
|
||||
<membership-item :key="message.id" v-else-if="message.type === MESSAGE_TYPE_MEMBER"
|
||||
class="style-scope yt-live-chat-item-list-renderer"
|
||||
:avatarUrl="message.avatarUrl" :authorName="getShowAuthorName(message)" :privilegeType="message.privilegeType"
|
||||
:title="message.title" :time="message.time"
|
||||
:time="message.time"
|
||||
:avatarUrl="message.avatarUrl"
|
||||
:authorName="getShowAuthorName(message)"
|
||||
:privilegeType="message.privilegeType"
|
||||
:title="message.title"
|
||||
></membership-item>
|
||||
<paid-message :key="message.id" v-else-if="message.type === MESSAGE_TYPE_SUPER_CHAT"
|
||||
class="style-scope yt-live-chat-item-list-renderer"
|
||||
:price="message.price" :avatarUrl="message.avatarUrl" :authorName="getShowAuthorName(message)"
|
||||
:time="message.time" :content="getShowContent(message)"
|
||||
:time="message.time"
|
||||
:avatarUrl="message.avatarUrl"
|
||||
:authorName="getShowAuthorName(message)"
|
||||
:price="message.price"
|
||||
:content="getShowContent(message)"
|
||||
></paid-message>
|
||||
</template>
|
||||
</div>
|
||||
@@ -41,10 +54,10 @@
|
||||
|
||||
<script>
|
||||
import * as chatConfig from '@/api/chatConfig'
|
||||
import Ticker from './Ticker.vue'
|
||||
import TextMessage from './TextMessage.vue'
|
||||
import MembershipItem from './MembershipItem.vue'
|
||||
import PaidMessage from './PaidMessage.vue'
|
||||
import Ticker from './Ticker'
|
||||
import TextMessage from './TextMessage'
|
||||
import MembershipItem from './MembershipItem'
|
||||
import PaidMessage from './PaidMessage'
|
||||
import * as constants from './constants'
|
||||
|
||||
// 只有要添加的消息需要平滑
|
||||
@@ -114,7 +127,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
canScrollToBottom() {
|
||||
return this.atBottom/* || this.allowScroll*/
|
||||
return this.atBottom/* || this.allowScroll */
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -137,6 +150,7 @@ export default {
|
||||
return constants.getGiftShowContent(message, this.showGiftName)
|
||||
},
|
||||
getShowContent: constants.getShowContent,
|
||||
getShowRichContent: constants.getShowRichContent,
|
||||
getShowAuthorName: constants.getShowAuthorName,
|
||||
|
||||
addMessage(message) {
|
||||
@@ -211,12 +225,12 @@ export default {
|
||||
this.delMessages([id])
|
||||
},
|
||||
delMessages(ids) {
|
||||
this.enqueueMessages(ids.map(id => {
|
||||
return {
|
||||
this.enqueueMessages(ids.map(
|
||||
id => ({
|
||||
type: constants.MESSAGE_TYPE_DEL,
|
||||
id
|
||||
}
|
||||
}))
|
||||
})
|
||||
))
|
||||
},
|
||||
clearMessages() {
|
||||
this.messages = []
|
||||
@@ -288,7 +302,7 @@ export default {
|
||||
this.emitSmoothedMessageTimerId = window.setTimeout(this.emitSmoothedMessages)
|
||||
}
|
||||
},
|
||||
messageNeedSmooth({type}) {
|
||||
messageNeedSmooth({ type }) {
|
||||
return NEED_SMOOTH_MESSAGE_TYPES.indexOf(type) !== -1
|
||||
},
|
||||
emitSmoothedMessages() {
|
||||
@@ -355,18 +369,18 @@ export default {
|
||||
|
||||
for (let message of messageGroup) {
|
||||
switch (message.type) {
|
||||
case constants.MESSAGE_TYPE_TEXT:
|
||||
case constants.MESSAGE_TYPE_GIFT:
|
||||
case constants.MESSAGE_TYPE_MEMBER:
|
||||
case constants.MESSAGE_TYPE_SUPER_CHAT:
|
||||
this.handleAddMessage(message)
|
||||
break
|
||||
case constants.MESSAGE_TYPE_DEL:
|
||||
this.handleDelMessage(message)
|
||||
break
|
||||
case constants.MESSAGE_TYPE_UPDATE:
|
||||
this.handleUpdateMessage(message)
|
||||
break
|
||||
case constants.MESSAGE_TYPE_TEXT:
|
||||
case constants.MESSAGE_TYPE_GIFT:
|
||||
case constants.MESSAGE_TYPE_MEMBER:
|
||||
case constants.MESSAGE_TYPE_SUPER_CHAT:
|
||||
this.handleAddMessage(message)
|
||||
break
|
||||
case constants.MESSAGE_TYPE_DEL:
|
||||
this.handleDelMessage(message)
|
||||
break
|
||||
case constants.MESSAGE_TYPE_UPDATE:
|
||||
this.handleUpdateMessage(message)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +403,7 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
handleDelMessage({id}) {
|
||||
handleDelMessage({ id }) {
|
||||
for (let arr of [this.messages, this.paidMessages, this.messagesBuffer]) {
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr[i].id === id) {
|
||||
@@ -400,7 +414,7 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
handleUpdateMessage({id, newValuesObj}) {
|
||||
handleUpdateMessage({ id, newValuesObj }) {
|
||||
// 遍历滚动的消息
|
||||
this.forEachRecentMessage(999999999, message => {
|
||||
if (message.id !== id) {
|
||||
@@ -468,7 +482,7 @@ export default {
|
||||
this.lastSmoothChatMessageAddMs = performance.now()
|
||||
}
|
||||
let interval = performance.now() - this.lastSmoothChatMessageAddMs
|
||||
this.chatRateMs = 0.9 * this.chatRateMs + 0.1 * interval
|
||||
this.chatRateMs = (0.9 * this.chatRateMs) + (0.1 * interval)
|
||||
if (this.isSmoothed) {
|
||||
if (this.chatRateMs < 400) {
|
||||
this.isSmoothed = false
|
||||
|
||||
49
frontend/src/i18n.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import Vue from 'vue'
|
||||
import VueI18n from 'vue-i18n'
|
||||
|
||||
import zh from '@/lang/zh'
|
||||
|
||||
let lastSetLocale = 'zh'
|
||||
let loadedLocales = ['zh']
|
||||
|
||||
Vue.use(VueI18n)
|
||||
|
||||
export async function setLocale(locale) {
|
||||
lastSetLocale = locale
|
||||
if (loadedLocales.indexOf(locale) === -1) {
|
||||
// eslint-disable-next-line prefer-template
|
||||
let langModule = await import('@/lang/' + locale)
|
||||
i18n.setLocaleMessage(locale, langModule.default)
|
||||
loadedLocales.push(locale)
|
||||
|
||||
// 加载完成之前又调用了setLocale,这次的不生效
|
||||
if (locale !== lastSetLocale) {
|
||||
return
|
||||
}
|
||||
}
|
||||
window.localStorage.lang = i18n.locale = locale
|
||||
}
|
||||
|
||||
export const i18n = new VueI18n({
|
||||
locale: 'zh',
|
||||
fallbackLocale: 'zh',
|
||||
messages: {
|
||||
zh
|
||||
}
|
||||
})
|
||||
|
||||
function getDefaultLocale() {
|
||||
let locale = window.localStorage.lang
|
||||
if (!locale) {
|
||||
let lang = navigator.language
|
||||
if (lang.startsWith('zh')) {
|
||||
locale = 'zh'
|
||||
} else if (lang.startsWith('ja')) {
|
||||
locale = 'ja'
|
||||
} else {
|
||||
locale = 'en'
|
||||
}
|
||||
}
|
||||
return locale
|
||||
}
|
||||
setLocale(getDefaultLocale())
|
||||
@@ -1,10 +1,10 @@
|
||||
export default {
|
||||
sidebar: {
|
||||
home: 'Home',
|
||||
stylegen: 'Style generator',
|
||||
stylegen: 'Style Generator',
|
||||
help: 'Help',
|
||||
projectAddress: 'Project address',
|
||||
giftRecordOfficial: 'Official Super Chat record',
|
||||
projectAddress: 'Project Address',
|
||||
giftRecordOfficial: 'Official Super Chat Record',
|
||||
},
|
||||
home: {
|
||||
roomIdEmpty: "Room ID can't be empty",
|
||||
@@ -38,8 +38,14 @@ export default {
|
||||
pinyin: 'Pinyin',
|
||||
kana: 'Kana',
|
||||
|
||||
emoticon: 'Custom Emotes',
|
||||
emoticonKeyword: 'Emote Code',
|
||||
emoticonUrl: 'URL',
|
||||
operation: 'Operation',
|
||||
addEmoticon: 'Add emote',
|
||||
emoticonFileTooLarge: 'File size is too large. Max size is 1MB',
|
||||
|
||||
roomUrl: 'Room URL',
|
||||
copy: 'Copy',
|
||||
enterRoom: 'Enter room',
|
||||
enterTestRoom: 'Enter test room',
|
||||
exportConfig: 'Export config',
|
||||
@@ -63,7 +69,7 @@ export default {
|
||||
showAvatars: 'Show avatars',
|
||||
avatarSize: 'Avatar size',
|
||||
|
||||
userNames: 'User names',
|
||||
userNames: 'User Names',
|
||||
showUserNames: 'Show user names',
|
||||
font: 'Font',
|
||||
fontSize: 'Font size',
|
||||
@@ -74,6 +80,7 @@ export default {
|
||||
memberColor: 'Member color',
|
||||
showBadges: 'Show badges',
|
||||
showColon: 'Show colon after name',
|
||||
emoticonSize: 'Emoticon size',
|
||||
|
||||
messages: 'Messages',
|
||||
color: 'Color',
|
||||
@@ -90,7 +97,7 @@ export default {
|
||||
moderatorMessageBgColor: 'Moderator background color',
|
||||
memberMessageBgColor: 'Member background color',
|
||||
|
||||
scAndNewMember: 'Super Chat / New member',
|
||||
scAndNewMember: 'Super Chat / New Member',
|
||||
firstLineFont: 'First line font',
|
||||
firstLineFontSize: 'First line font size',
|
||||
firstLineLineHeight: 'First line line height (0 for default)',
|
||||
@@ -128,5 +135,14 @@ export default {
|
||||
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'
|
||||
},
|
||||
chat: {
|
||||
moderator: 'moderator',
|
||||
guardLevel1: 'governor',
|
||||
guardLevel2: 'admiral',
|
||||
guardLevel3: 'captain',
|
||||
sendGift: 'Sent {giftName}x{num}',
|
||||
membershipTitle: 'New member',
|
||||
tickerMembership: 'Member'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,14 @@ export default {
|
||||
pinyin: 'ピンイン',
|
||||
kana: '仮名',
|
||||
|
||||
emoticon: 'カスタムスタンプ',
|
||||
emoticonKeyword: '置き換えるキーワード',
|
||||
emoticonUrl: 'URL',
|
||||
operation: '操作',
|
||||
addEmoticon: 'スタンプを追加',
|
||||
emoticonFileTooLarge: 'ファイルサイズが大きすぎます。最大サイズは1MBです',
|
||||
|
||||
roomUrl: 'ルームのURL',
|
||||
copy: 'コピー',
|
||||
enterRoom: 'ルームに入る',
|
||||
enterTestRoom: 'テストルームに入る',
|
||||
exportConfig: 'コンフィグの導出',
|
||||
@@ -74,6 +80,7 @@ export default {
|
||||
memberColor: 'メンバーの色',
|
||||
showBadges: '勲章を見せる',
|
||||
showColon: 'ユーザー名の後にコロンが表示されます',
|
||||
emoticonSize: 'スタンプサイズ',
|
||||
|
||||
messages: 'コメント',
|
||||
color: '色',
|
||||
@@ -128,5 +135,14 @@ export default {
|
||||
p3: '3. スタイルジェネレータでお好みのコメント様子を選び、出力したCSSをコピーする',
|
||||
p4: '4. OBSでブラウザを新規作成する',
|
||||
p5: '5. プロパティでこぴーしたURLを入力し、カスタムCSSでスタイルジェネレータのCSSを入力する'
|
||||
},
|
||||
chat: {
|
||||
moderator: 'モデレーター',
|
||||
guardLevel1: '総督',
|
||||
guardLevel2: '提督',
|
||||
guardLevel3: '艦長',
|
||||
sendGift: '{giftName}x{num} を贈りました',
|
||||
membershipTitle: '新規メンバー',
|
||||
tickerMembership: 'メンバー'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,8 +38,14 @@ export default {
|
||||
pinyin: '拼音',
|
||||
kana: '日文假名',
|
||||
|
||||
emoticon: '自定义表情',
|
||||
emoticonKeyword: '替换关键词',
|
||||
emoticonUrl: 'URL',
|
||||
operation: '操作',
|
||||
addEmoticon: '添加表情',
|
||||
emoticonFileTooLarge: '文件尺寸太大,最大1MB',
|
||||
|
||||
roomUrl: '房间URL',
|
||||
copy: '复制',
|
||||
enterRoom: '进入房间',
|
||||
enterTestRoom: '进入测试房间',
|
||||
exportConfig: '导出配置',
|
||||
@@ -74,6 +80,7 @@ export default {
|
||||
memberColor: '舰长颜色',
|
||||
showBadges: '显示勋章',
|
||||
showColon: '用户名后显示冒号',
|
||||
emoticonSize: '表情大小',
|
||||
|
||||
messages: '消息',
|
||||
color: '颜色',
|
||||
@@ -128,5 +135,14 @@ export default {
|
||||
p3: '3. 使用样式生成器生成样式,复制CSS',
|
||||
p4: '4. 在OBS中添加浏览器源',
|
||||
p5: '5. URL处输入之前复制的房间URL,自定义CSS处输入之前复制的CSS'
|
||||
},
|
||||
chat: {
|
||||
moderator: '管理员',
|
||||
guardLevel1: '总督',
|
||||
guardLevel2: '提督',
|
||||
guardLevel3: '舰长',
|
||||
sendGift: '赠送 {giftName}x{num}',
|
||||
membershipTitle: '新会员',
|
||||
tickerMembership: '会员'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,46 +8,53 @@
|
||||
:default-active="$route.path"
|
||||
>
|
||||
<el-menu-item index="/">
|
||||
<i class="el-icon-s-home"></i>{{$t('sidebar.home')}}
|
||||
<i class="el-icon-s-home"></i>{{ $t('sidebar.home') }}
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="$router.resolve({name: 'stylegen'}).href">
|
||||
<i class="el-icon-brush"></i>{{$t('sidebar.stylegen')}}
|
||||
<el-menu-item :index="$router.resolve({ name: 'stylegen' }).href">
|
||||
<i class="el-icon-brush"></i>{{ $t('sidebar.stylegen') }}
|
||||
</el-menu-item>
|
||||
<el-menu-item :index="$router.resolve({name: 'help'}).href">
|
||||
<i class="el-icon-question"></i>{{$t('sidebar.help')}}
|
||||
<el-menu-item :index="$router.resolve({ name: 'help' }).href">
|
||||
<i class="el-icon-question"></i>{{ $t('sidebar.help') }}
|
||||
</el-menu-item>
|
||||
<a href="https://github.com/xfgryujk/blivechat" target="_blank">
|
||||
<el-menu-item>
|
||||
<i class="el-icon-share"></i>{{$t('sidebar.projectAddress')}}
|
||||
<i class="el-icon-share"></i>{{ $t('sidebar.projectAddress') }}
|
||||
</el-menu-item>
|
||||
</a>
|
||||
<a href="http://link.bilibili.com/ctool/vtuber" target="_blank">
|
||||
<el-menu-item>
|
||||
<i class="el-icon-link"></i>{{$t('sidebar.giftRecordOfficial')}}
|
||||
<i class="el-icon-link"></i>{{ $t('sidebar.giftRecordOfficial') }}
|
||||
</el-menu-item>
|
||||
</a>
|
||||
<el-submenu index="null">
|
||||
<template slot="title">
|
||||
<i class="el-icon-chat-line-square"></i>Language
|
||||
</template>
|
||||
<el-menu-item v-for="{locale, name} in [
|
||||
{locale: 'zh', name: '中文'},
|
||||
{locale: 'ja', name: '日本語'},
|
||||
{locale: 'en', name: 'English'}
|
||||
]" :key="locale"
|
||||
@click="onSelectLanguage(locale)"
|
||||
>{{name}}</el-menu-item>
|
||||
<el-menu-item v-for="locale in LOCALES" :key="locale.locale" @click="onSelectLanguage(locale.locale)">
|
||||
<template>{{ locale.name }}</template>
|
||||
</el-menu-item>
|
||||
</el-submenu>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as i18n from '@/i18n'
|
||||
|
||||
export default {
|
||||
name: 'Sidebar',
|
||||
data() {
|
||||
return {
|
||||
LOCALES: [
|
||||
{ locale: 'zh', name: '中文' },
|
||||
{ locale: 'ja', name: '日本語' },
|
||||
{ locale: 'en', name: 'English' }
|
||||
]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onSelectLanguage(locale) {
|
||||
window.localStorage.lang = this.$i18n.locale = locale
|
||||
i18n.setLocale(locale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<template>
|
||||
<el-container class="app-wrapper" :class="{mobile: isMobile}">
|
||||
<el-container class="app-wrapper" :class="{ mobile: isMobile }">
|
||||
<div v-show="isMobile && !hideSidebar" class="drawer-bg" @click="hideSidebar = true"></div>
|
||||
<el-aside width="230px" class="sidebar-container" :class="{'hide-sidebar': hideSidebar}">
|
||||
<el-aside width="230px" class="sidebar-container" :class="{ 'hide-sidebar': hideSidebar }">
|
||||
<div class="logo-container">
|
||||
<router-link to="/">
|
||||
<img src="@/assets/img/logo.png" class="sidebar-logo">
|
||||
<h1 class="sidebar-title">blivechat</h1>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="version">
|
||||
v1.5.2
|
||||
</div>
|
||||
<div class="version">{{ APP_VERSION }}</div>
|
||||
<sidebar></sidebar>
|
||||
</el-aside>
|
||||
<el-main>
|
||||
@@ -23,7 +21,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Sidebar from './Sidebar.vue'
|
||||
import Sidebar from './Sidebar'
|
||||
|
||||
export default {
|
||||
name: 'Layout',
|
||||
@@ -32,6 +30,8 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
APP_VERSION: process.env.APP_VERSION,
|
||||
|
||||
isMobile: false,
|
||||
hideSidebar: true
|
||||
}
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
import Vue from 'vue'
|
||||
import VueRouter from 'vue-router'
|
||||
import VueI18n from 'vue-i18n'
|
||||
import {
|
||||
Aside, Autocomplete, Badge, Button, Card, Col, ColorPicker, Container, Divider, Form, FormItem, Image,
|
||||
Aside, Autocomplete, Badge, Button, ButtonGroup, Card, Col, ColorPicker, Container, Divider, Form, FormItem, Image,
|
||||
Input, Main, Menu, MenuItem, Message, Option, OptionGroup, Radio, RadioGroup, Row, Select, Scrollbar,
|
||||
Slider, Submenu, Switch, TabPane, Tabs, Tooltip
|
||||
Slider, Submenu, Switch, Table, TableColumn, TabPane, Tabs, Tooltip
|
||||
} from 'element-ui'
|
||||
import axios from 'axios'
|
||||
|
||||
import App from './App.vue'
|
||||
import * as i18n from './i18n'
|
||||
import App from './App'
|
||||
import Layout from './layout'
|
||||
import Home from './views/Home.vue'
|
||||
import Home from './views/Home'
|
||||
import StyleGenerator from './views/StyleGenerator'
|
||||
import Help from './views/Help'
|
||||
import Room from './views/Room.vue'
|
||||
import NotFound from './views/NotFound.vue'
|
||||
import Room from './views/Room'
|
||||
import NotFound from './views/NotFound'
|
||||
|
||||
import zh from './lang/zh'
|
||||
import ja from './lang/ja'
|
||||
import en from './lang/en'
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// 开发时使用localhost:12450
|
||||
axios.defaults.baseURL = 'http://localhost:12450'
|
||||
}
|
||||
axios.defaults.timeout = 10 * 1000
|
||||
|
||||
Vue.use(VueRouter)
|
||||
Vue.use(VueI18n)
|
||||
// 初始化element
|
||||
Vue.use(Aside)
|
||||
Vue.use(Autocomplete)
|
||||
Vue.use(Badge)
|
||||
Vue.use(Button)
|
||||
Vue.use(ButtonGroup)
|
||||
Vue.use(Card)
|
||||
Vue.use(Col)
|
||||
Vue.use(ColorPicker)
|
||||
@@ -55,6 +47,8 @@ Vue.use(Scrollbar)
|
||||
Vue.use(Slider)
|
||||
Vue.use(Submenu)
|
||||
Vue.use(Switch)
|
||||
Vue.use(Table)
|
||||
Vue.use(TableColumn)
|
||||
Vue.use(TabPane)
|
||||
Vue.use(Tabs)
|
||||
Vue.use(Tooltip)
|
||||
@@ -71,12 +65,17 @@ const router = new VueRouter({
|
||||
path: '/',
|
||||
component: Layout,
|
||||
children: [
|
||||
{path: '', component: Home},
|
||||
{path: 'stylegen', name: 'stylegen', component: StyleGenerator},
|
||||
{path: 'help', name: 'help', component: Help}
|
||||
{ path: '', component: Home },
|
||||
{ path: 'stylegen', name: 'stylegen', component: StyleGenerator },
|
||||
{ path: 'help', name: 'help', component: Help }
|
||||
]
|
||||
},
|
||||
{path: '/room/test', name: 'test_room', component: Room, props: route => ({strConfig: route.query})},
|
||||
{
|
||||
path: '/room/test',
|
||||
name: 'test_room',
|
||||
component: Room,
|
||||
props: route => ({ strConfig: route.query })
|
||||
},
|
||||
{
|
||||
path: '/room/:roomId',
|
||||
name: 'room',
|
||||
@@ -86,34 +85,15 @@ const router = new VueRouter({
|
||||
if (isNaN(roomId)) {
|
||||
roomId = null
|
||||
}
|
||||
return {roomId, strConfig: route.query}
|
||||
return { roomId, strConfig: route.query }
|
||||
}
|
||||
},
|
||||
{path: '*', component: NotFound}
|
||||
{ path: '*', component: NotFound }
|
||||
]
|
||||
})
|
||||
|
||||
let locale = window.localStorage.lang
|
||||
if (!locale) {
|
||||
let lang = navigator.language
|
||||
if (lang.startsWith('zh')) {
|
||||
locale = 'zh'
|
||||
} else if (lang.startsWith('ja')) {
|
||||
locale = 'ja'
|
||||
} else {
|
||||
locale = 'en'
|
||||
}
|
||||
}
|
||||
const i18n = new VueI18n({
|
||||
locale,
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
zh, ja, en
|
||||
}
|
||||
})
|
||||
|
||||
new Vue({
|
||||
render: h => h(App),
|
||||
router,
|
||||
i18n
|
||||
i18n: i18n.i18n
|
||||
}).$mount('#app')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export function mergeConfig (config, defaultConfig) {
|
||||
export function mergeConfig(config, defaultConfig) {
|
||||
let res = {}
|
||||
for (let i in defaultConfig) {
|
||||
res[i] = i in config ? config[i] : defaultConfig[i]
|
||||
@@ -6,14 +6,14 @@ export function mergeConfig (config, defaultConfig) {
|
||||
return res
|
||||
}
|
||||
|
||||
export function toBool (val) {
|
||||
export function toBool(val) {
|
||||
if (typeof val === 'string') {
|
||||
return ['false', 'no', 'off', '0', ''].indexOf(val.toLowerCase()) === -1
|
||||
}
|
||||
return !!val
|
||||
return Boolean(val)
|
||||
}
|
||||
|
||||
export function toInt (val, _default) {
|
||||
export function toInt(val, _default) {
|
||||
let res = parseInt(val)
|
||||
if (isNaN(res)) {
|
||||
res = _default
|
||||
@@ -21,19 +21,19 @@ export function toInt (val, _default) {
|
||||
return res
|
||||
}
|
||||
|
||||
export function formatCurrency (price) {
|
||||
export function formatCurrency(price) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
minimumFractionDigits: price < 100 ? 2 : 0
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
export function getTimeTextHourMin (date) {
|
||||
export function getTimeTextHourMin(date) {
|
||||
let hour = date.getHours()
|
||||
let min = ('00' + date.getMinutes()).slice(-2)
|
||||
let min = `00${date.getMinutes()}`.slice(-2)
|
||||
return `${hour}:${min}`
|
||||
}
|
||||
|
||||
export function getUuid4Hex () {
|
||||
export function getUuid4Hex() {
|
||||
let chars = []
|
||||
for (let i = 0; i < 32; i++) {
|
||||
let char = Math.floor(Math.random() * 16).toString(16)
|
||||
|
||||
@@ -2,21 +2,21 @@ export const DICT_PINYIN = 'pinyin'
|
||||
export const DICT_KANA = 'kana'
|
||||
|
||||
export class PronunciationConverter {
|
||||
constructor () {
|
||||
constructor() {
|
||||
this.pronunciationMap = new Map()
|
||||
}
|
||||
|
||||
async loadDict (dictName) {
|
||||
async loadDict(dictName) {
|
||||
let promise
|
||||
switch (dictName) {
|
||||
case DICT_PINYIN:
|
||||
promise = import('./dictPinyin')
|
||||
break
|
||||
case DICT_KANA:
|
||||
promise = import('./dictKana')
|
||||
break
|
||||
default:
|
||||
return
|
||||
case DICT_PINYIN:
|
||||
promise = import('./dictPinyin')
|
||||
break
|
||||
case DICT_KANA:
|
||||
promise = import('./dictKana')
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
let dictTxt = (await promise).default
|
||||
@@ -30,7 +30,7 @@ export class PronunciationConverter {
|
||||
this.pronunciationMap = pronunciationMap
|
||||
}
|
||||
|
||||
getPronunciation (text) {
|
||||
getPronunciation(text) {
|
||||
let res = []
|
||||
let lastHasPronunciation = null
|
||||
for (let char of text) {
|
||||
|
||||
58
frontend/src/utils/trie.js
Normal file
@@ -0,0 +1,58 @@
|
||||
export class Trie {
|
||||
constructor() {
|
||||
this._root = this._createNode()
|
||||
}
|
||||
|
||||
_createNode() {
|
||||
return {
|
||||
children: {}, // char -> node
|
||||
value: null
|
||||
}
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
if (key === '') {
|
||||
throw new Error('key is empty')
|
||||
}
|
||||
let node = this._root
|
||||
for (let char of key) {
|
||||
let nextNode = node.children[char]
|
||||
if (nextNode === undefined) {
|
||||
nextNode = node.children[char] = this._createNode()
|
||||
}
|
||||
node = nextNode
|
||||
}
|
||||
node.value = value
|
||||
}
|
||||
|
||||
get(key) {
|
||||
let node = this._root
|
||||
for (let char of key) {
|
||||
let nextNode = node.children[char]
|
||||
if (nextNode === undefined) {
|
||||
return null
|
||||
}
|
||||
node = nextNode
|
||||
}
|
||||
return node.value
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return this.get(key) !== null
|
||||
}
|
||||
|
||||
greedyMatch(str) {
|
||||
let node = this._root
|
||||
for (let char of str) {
|
||||
let nextNode = node.children[char]
|
||||
if (nextNode === undefined) {
|
||||
return null
|
||||
}
|
||||
if (nextNode.value !== null) {
|
||||
return nextNode.value
|
||||
}
|
||||
node = nextNode
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>{{$t('help.help')}}</h1>
|
||||
<p>{{$t('help.p1')}}</p>
|
||||
<h1>{{ $t('help.help') }}</h1>
|
||||
<p>{{ $t('help.p1') }}</p>
|
||||
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-1.png"></el-image></p>
|
||||
<p>{{$t('help.p2')}}</p>
|
||||
<p>{{ $t('help.p2') }}</p>
|
||||
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-2.png"></el-image></p>
|
||||
<p>{{$t('help.p3')}}</p>
|
||||
<p>{{ $t('help.p3') }}</p>
|
||||
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-3.png"></el-image></p>
|
||||
<p>{{$t('help.p4')}}</p>
|
||||
<p>{{ $t('help.p4') }}</p>
|
||||
<p class="img-container"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-4.png"></el-image></p>
|
||||
<p>{{$t('help.p5')}}</p>
|
||||
<p>{{ $t('help.p5') }}</p>
|
||||
<p class="img-container large-img"><el-image fit="scale-down" src="/static/img/tutorial/tutorial-5.png"></el-image></p>
|
||||
<p><br><br><br><br><br><br><br><br>--------------------------------------------------------------------------------------------------------</p>
|
||||
<p>喜欢的话可以推荐给别人,专栏求支持_(:з」∠)_ <a href="https://www.bilibili.com/read/cv4594365" target="_blank">https://www.bilibili.com/read/cv4594365</a></p>
|
||||
<p>喜欢的话可以推荐给别人,专栏求支持_(:з」∠)_ <a href="https://www.bilibili.com/read/cv4594365" target="_blank">https://www.bilibili.com/read/cv4594365</a></p>
|
||||
<p>配置官方翻译接口傻瓜式教程 <a href="https://www.bilibili.com/read/cv14663633" target="_blank">https://www.bilibili.com/read/cv14663633</a>。注意必须下载到本地才能改后台配置,本地使用方法看 <a href="https://github.com/xfgryujk/blivechat#%E4%BD%BF%E7%94%A8%E6%96%B9%E6%B3%95" target="_blank">项目地址中的使用方法说明</a></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -114,6 +114,34 @@
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="$t('home.emoticon')">
|
||||
<el-table :data="form.emoticons">
|
||||
<el-table-column prop="keyword" :label="$t('home.emoticonKeyword')" width="170">
|
||||
<template slot-scope="scope">
|
||||
<el-input v-model="scope.row.keyword"></el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="url" :label="$t('home.emoticonUrl')">
|
||||
<template slot-scope="scope">
|
||||
<el-input v-model="scope.row.url"></el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('home.operation')" width="170">
|
||||
<template slot-scope="scope">
|
||||
<el-button-group>
|
||||
<el-button type="primary" icon="el-icon-upload2" :disabled="!serverConfig.enableUploadFile"
|
||||
@click="uploadEmoticon(scope.row)"
|
||||
></el-button>
|
||||
<el-button type="danger" icon="el-icon-minus" @click="delEmoticon(scope.$index)"></el-button>
|
||||
</el-button-group>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<p>
|
||||
<el-button type="primary" icon="el-icon-plus" @click="addEmoticon">{{$t('home.addEmoticon')}}</el-button>
|
||||
</p>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-form>
|
||||
</p>
|
||||
@@ -123,11 +151,11 @@
|
||||
<el-form :model="form" label-width="150px">
|
||||
<el-form-item :label="$t('home.roomUrl')">
|
||||
<el-input ref="roomUrlInput" readonly :value="obsRoomUrl" style="width: calc(100% - 8em); margin-right: 1em;"></el-input>
|
||||
<el-button type="primary" @click="copyUrl">{{$t('home.copy')}}</el-button>
|
||||
<el-button type="primary" icon="el-icon-copy-document" @click="copyUrl"></el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :disabled="!roomUrl" @click="enterRoom">{{$t('home.enterRoom')}}</el-button>
|
||||
<el-button :disabled="!roomUrl" @click="enterTestRoom">{{$t('home.enterTestRoom')}}</el-button>
|
||||
<el-button @click="enterTestRoom">{{$t('home.enterTestRoom')}}</el-button>
|
||||
<el-button @click="exportConfig">{{$t('home.exportConfig')}}</el-button>
|
||||
<el-button @click="importConfig">{{$t('home.importConfig')}}</el-button>
|
||||
</el-form-item>
|
||||
@@ -139,10 +167,10 @@
|
||||
|
||||
<script>
|
||||
import _ from 'lodash'
|
||||
import axios from 'axios'
|
||||
import download from 'downloadjs'
|
||||
|
||||
import {mergeConfig} from '@/utils'
|
||||
import { mergeConfig } from '@/utils'
|
||||
import * as mainApi from '@/api/main'
|
||||
import * as chatConfig from '@/api/chatConfig'
|
||||
|
||||
export default {
|
||||
@@ -151,11 +179,12 @@ export default {
|
||||
return {
|
||||
serverConfig: {
|
||||
enableTranslate: true,
|
||||
enableUploadFile: true,
|
||||
loaderUrl: ''
|
||||
},
|
||||
form: {
|
||||
roomId: parseInt(window.localStorage.roomId || '1'),
|
||||
...chatConfig.getLocalConfig()
|
||||
...chatConfig.getLocalConfig(),
|
||||
roomId: parseInt(window.localStorage.roomId || '1')
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -187,11 +216,45 @@ export default {
|
||||
methods: {
|
||||
async updateServerConfig() {
|
||||
try {
|
||||
this.serverConfig = (await axios.get('/api/server_info')).data.config
|
||||
this.serverConfig = (await mainApi.getServerInfo()).config
|
||||
} catch (e) {
|
||||
this.$message.error('Failed to fetch server information: ' + e)
|
||||
this.$message.error(`Failed to fetch server information: ${e}`)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
|
||||
addEmoticon() {
|
||||
this.form.emoticons.push({
|
||||
keyword: '[Kappa]',
|
||||
url: ''
|
||||
})
|
||||
},
|
||||
delEmoticon(index) {
|
||||
this.form.emoticons.splice(index, 1)
|
||||
},
|
||||
uploadEmoticon(emoticon) {
|
||||
let input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/png, image/jpeg, image/jpg, image/gif'
|
||||
input.onchange = async() => {
|
||||
let file = input.files[0]
|
||||
if (file.size > 1024 * 1024) {
|
||||
this.$message.error(this.$t('home.emoticonFileTooLarge'))
|
||||
return
|
||||
}
|
||||
|
||||
let res
|
||||
try {
|
||||
res = await mainApi.uploadEmoticon(file)
|
||||
} catch (e) {
|
||||
this.$message.error(`Failed to upload: ${e}`)
|
||||
throw e
|
||||
}
|
||||
emoticon.url = res.url
|
||||
}
|
||||
input.click()
|
||||
},
|
||||
|
||||
enterRoom() {
|
||||
window.open(this.roomUrl, `room ${this.form.roomId}`, 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
|
||||
},
|
||||
@@ -199,16 +262,22 @@ export default {
|
||||
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.form.roomId === '') {
|
||||
return ''
|
||||
}
|
||||
let query = {...this.form}
|
||||
|
||||
let query = {
|
||||
...this.form,
|
||||
emoticons: JSON.stringify(this.form.emoticons),
|
||||
lang: this.$i18n.locale
|
||||
}
|
||||
delete query.roomId
|
||||
|
||||
let resolved
|
||||
if (isTestRoom) {
|
||||
resolved = this.$router.resolve({name: 'test_room', query})
|
||||
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: { roomId: this.form.roomId }, query })
|
||||
}
|
||||
return `${window.location.protocol}//${window.location.host}${resolved.href}`
|
||||
},
|
||||
@@ -234,12 +303,19 @@ export default {
|
||||
this.$message.error(this.$t('home.failedToParseConfig') + e)
|
||||
return
|
||||
}
|
||||
cfg = mergeConfig(cfg, chatConfig.DEFAULT_CONFIG)
|
||||
this.form = {roomId: this.form.roomId, ...cfg}
|
||||
this.importConfigFromObj(cfg)
|
||||
}
|
||||
reader.readAsText(input.files[0])
|
||||
}
|
||||
input.click()
|
||||
},
|
||||
importConfigFromObj(cfg) {
|
||||
cfg = mergeConfig(cfg, chatConfig.deepCloneDefaultConfig())
|
||||
chatConfig.sanitizeConfig(cfg)
|
||||
this.form = {
|
||||
...cfg,
|
||||
roomId: this.form.roomId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mergeConfig, toBool, toInt} from '@/utils'
|
||||
import * as i18n from '@/i18n'
|
||||
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 ChatClientTest from '@/api/chat/ChatClientTest'
|
||||
@@ -29,17 +31,40 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config: {...chatConfig.DEFAULT_CONFIG},
|
||||
config: chatConfig.deepCloneDefaultConfig(),
|
||||
chatClient: null,
|
||||
pronunciationConverter: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
blockKeywords() {
|
||||
return this.config.blockKeywords.split('\n').filter(val => val)
|
||||
blockKeywordsTrie() {
|
||||
let blockKeywords = this.config.blockKeywords.split('\n')
|
||||
let res = new trie.Trie()
|
||||
for (let keyword of blockKeywords) {
|
||||
if (keyword !== '') {
|
||||
res.set(keyword, true)
|
||||
}
|
||||
}
|
||||
return res
|
||||
},
|
||||
blockUsers() {
|
||||
return this.config.blockUsers.split('\n').filter(val => val)
|
||||
blockUsersTrie() {
|
||||
let blockUsers = this.config.blockUsers.split('\n')
|
||||
let res = new trie.Trie()
|
||||
for (let user of blockUsers) {
|
||||
if (user !== '') {
|
||||
res.set(user, true)
|
||||
}
|
||||
}
|
||||
return res
|
||||
},
|
||||
emoticonsTrie() {
|
||||
let res = new trie.Trie()
|
||||
for (let emoticon of this.config.emoticons) {
|
||||
if (emoticon.keyword !== '' && emoticon.url !== '') {
|
||||
res.set(emoticon.keyword, emoticon)
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -63,6 +88,11 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
initConfig() {
|
||||
let locale = this.strConfig.lang
|
||||
if (locale) {
|
||||
i18n.setLocale(locale)
|
||||
}
|
||||
|
||||
let cfg = {}
|
||||
// 留空的使用默认值
|
||||
for (let i in this.strConfig) {
|
||||
@@ -70,7 +100,7 @@ export default {
|
||||
cfg[i] = this.strConfig[i]
|
||||
}
|
||||
}
|
||||
cfg = mergeConfig(cfg, chatConfig.DEFAULT_CONFIG)
|
||||
cfg = mergeConfig(cfg, chatConfig.deepCloneDefaultConfig())
|
||||
|
||||
cfg.minGiftPrice = toInt(cfg.minGiftPrice, chatConfig.DEFAULT_CONFIG.minGiftPrice)
|
||||
cfg.showDanmaku = toBool(cfg.showDanmaku)
|
||||
@@ -79,16 +109,30 @@ export default {
|
||||
cfg.mergeSimilarDanmaku = toBool(cfg.mergeSimilarDanmaku)
|
||||
cfg.mergeGift = toBool(cfg.mergeGift)
|
||||
cfg.maxNumber = toInt(cfg.maxNumber, chatConfig.DEFAULT_CONFIG.maxNumber)
|
||||
|
||||
cfg.blockGiftDanmaku = toBool(cfg.blockGiftDanmaku)
|
||||
cfg.blockLevel = toInt(cfg.blockLevel, chatConfig.DEFAULT_CONFIG.blockLevel)
|
||||
cfg.blockNewbie = toBool(cfg.blockNewbie)
|
||||
cfg.blockNotMobileVerified = toBool(cfg.blockNotMobileVerified)
|
||||
cfg.blockMedalLevel = toInt(cfg.blockMedalLevel, chatConfig.DEFAULT_CONFIG.blockMedalLevel)
|
||||
|
||||
cfg.relayMessagesByServer = toBool(cfg.relayMessagesByServer)
|
||||
cfg.autoTranslate = toBool(cfg.autoTranslate)
|
||||
cfg.emoticons = this.toObjIfJson(cfg.emoticons)
|
||||
|
||||
chatConfig.sanitizeConfig(cfg)
|
||||
this.config = cfg
|
||||
},
|
||||
toObjIfJson(str) {
|
||||
if (typeof str !== 'string') {
|
||||
return str
|
||||
}
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
initChatClient() {
|
||||
if (this.roomId === null) {
|
||||
this.chatClient = new ChatClientTest()
|
||||
@@ -127,6 +171,7 @@ export default {
|
||||
authorName: data.authorName,
|
||||
authorType: data.authorType,
|
||||
content: data.content,
|
||||
richContent: this.getRichContent(data),
|
||||
privilegeType: data.privilegeType,
|
||||
repeated: 1,
|
||||
translation: data.translation
|
||||
@@ -169,7 +214,7 @@ export default {
|
||||
authorName: data.authorName,
|
||||
authorNamePronunciation: this.getPronunciation(data.authorName),
|
||||
privilegeType: data.privilegeType,
|
||||
title: 'New member'
|
||||
title: this.$t('chat.membershipTitle')
|
||||
}
|
||||
this.$refs.renderer.addMessage(message)
|
||||
},
|
||||
@@ -194,15 +239,13 @@ export default {
|
||||
this.$refs.renderer.addMessage(message)
|
||||
},
|
||||
onDelSuperChat(data) {
|
||||
for (let id of data.ids) {
|
||||
this.$refs.renderer.delMessage(id)
|
||||
}
|
||||
this.$refs.renderer.delMessages(data.ids)
|
||||
},
|
||||
onUpdateTranslation(data) {
|
||||
if (!this.config.autoTranslate) {
|
||||
return
|
||||
}
|
||||
this.$refs.renderer.updateMessage(data.id, {translation: data.translation})
|
||||
this.$refs.renderer.updateMessage(data.id, { translation: data.translation })
|
||||
},
|
||||
|
||||
filterTextMessage(data) {
|
||||
@@ -217,24 +260,27 @@ export default {
|
||||
} else if (this.config.blockMedalLevel > 0 && data.medalLevel < this.config.blockMedalLevel) {
|
||||
return false
|
||||
}
|
||||
return this.filterSuperChatMessage(data)
|
||||
return this.filterByContent(data.content) && this.filterByAuthorName(data.authorName)
|
||||
},
|
||||
filterSuperChatMessage(data) {
|
||||
for (let keyword of this.blockKeywords) {
|
||||
if (data.content.indexOf(keyword) !== -1) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return this.filterNewMemberMessage(data)
|
||||
return this.filterByContent(data.content) && this.filterByAuthorName(data.authorName)
|
||||
},
|
||||
filterNewMemberMessage(data) {
|
||||
for (let user of this.blockUsers) {
|
||||
if (data.authorName === user) {
|
||||
return this.filterByAuthorName(data.authorName)
|
||||
},
|
||||
filterByContent(content) {
|
||||
let blockKeywordsTrie = this.blockKeywordsTrie
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
let remainContent = content.substring(i)
|
||||
if (blockKeywordsTrie.greedyMatch(remainContent) !== null) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
filterByAuthorName(authorName) {
|
||||
return !this.blockUsersTrie.has(authorName)
|
||||
},
|
||||
mergeSimilarText(content) {
|
||||
if (!this.config.mergeSimilarDanmaku) {
|
||||
return false
|
||||
@@ -252,6 +298,66 @@ export default {
|
||||
return ''
|
||||
}
|
||||
return this.pronunciationConverter.getPronunciation(text)
|
||||
},
|
||||
getRichContent(data) {
|
||||
let richContent = []
|
||||
|
||||
// B站官方表情
|
||||
if (data.emoticon !== null) {
|
||||
richContent.push({
|
||||
type: constants.CONTENT_TYPE_IMAGE,
|
||||
text: data.content,
|
||||
url: data.emoticon
|
||||
})
|
||||
return richContent
|
||||
}
|
||||
|
||||
// 没有自定义表情,只能是文本
|
||||
if (this.config.emoticons.length === 0) {
|
||||
richContent.push({
|
||||
type: constants.CONTENT_TYPE_TEXT,
|
||||
text: data.content
|
||||
})
|
||||
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)
|
||||
if (matchEmoticon === null) {
|
||||
pos++
|
||||
continue
|
||||
}
|
||||
|
||||
// 加入之前的文本
|
||||
if (pos !== startPos) {
|
||||
richContent.push({
|
||||
type: constants.CONTENT_TYPE_TEXT,
|
||||
text: data.content.slice(startPos, pos)
|
||||
})
|
||||
}
|
||||
|
||||
// 加入表情
|
||||
richContent.push({
|
||||
type: constants.CONTENT_TYPE_IMAGE,
|
||||
text: matchEmoticon.keyword,
|
||||
url: matchEmoticon.url
|
||||
})
|
||||
pos += matchEmoticon.keyword.length
|
||||
startPos = pos
|
||||
}
|
||||
// 加入尾部的文本
|
||||
if (pos !== startPos) {
|
||||
richContent.push({
|
||||
type: constants.CONTENT_TYPE_TEXT,
|
||||
text: data.content.slice(startPos, pos)
|
||||
})
|
||||
}
|
||||
return richContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form label-width="150px" size="mini">
|
||||
<h3>{{$t('stylegen.outlines')}}</h3>
|
||||
<h3>{{ $t('stylegen.outlines') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
@@ -20,7 +20,7 @@
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
|
||||
<h3>{{$t('stylegen.avatars')}}</h3>
|
||||
<h3>{{ $t('stylegen.avatars') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
@@ -36,7 +36,7 @@
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<h3>{{$t('stylegen.userNames')}}</h3>
|
||||
<h3>{{ $t('stylegen.userNames') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
@@ -100,7 +100,7 @@
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<h3>{{$t('stylegen.messages')}}</h3>
|
||||
<h3>{{ $t('stylegen.messages') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
@@ -129,9 +129,16 @@
|
||||
<el-form-item :label="$t('stylegen.onNewLine')">
|
||||
<el-switch v-model="form.messageOnNewLine"></el-switch>
|
||||
</el-form-item>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.emoticonSize')">
|
||||
<el-input v-model.number="form.emoticonSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<h3>{{$t('stylegen.time')}}</h3>
|
||||
<h3>{{ $t('stylegen.time') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-form-item :label="$t('stylegen.showTime')">
|
||||
<el-switch v-model="form.showTime"></el-switch>
|
||||
@@ -162,7 +169,7 @@
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<h3>{{$t('stylegen.backgrounds')}}</h3>
|
||||
<h3>{{ $t('stylegen.backgrounds') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
@@ -202,7 +209,7 @@
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<h3>{{$t('stylegen.scAndNewMember')}}</h3>
|
||||
<h3>{{ $t('stylegen.scAndNewMember') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
@@ -299,7 +306,7 @@
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<h3>{{$t('stylegen.animation')}}</h3>
|
||||
<h3>{{ $t('stylegen.animation') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
@@ -350,7 +357,7 @@ import _ from 'lodash'
|
||||
|
||||
import FontSelect from './FontSelect'
|
||||
import * as common from './common'
|
||||
import {mergeConfig} from '@/utils'
|
||||
import { mergeConfig } from '@/utils'
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
showOutlines: true,
|
||||
@@ -376,6 +383,7 @@ export const DEFAULT_CONFIG = {
|
||||
messageLineHeight: 0,
|
||||
messageColor: '#ffffff',
|
||||
messageOnNewLine: false,
|
||||
emoticonSize: 18,
|
||||
|
||||
showTime: false,
|
||||
timeFont: 'Imprima',
|
||||
@@ -476,7 +484,7 @@ yt-live-chat-renderer * {
|
||||
line-height: ${this.form.messageLineHeight || this.form.messageFontSize}px !important;
|
||||
}`
|
||||
},
|
||||
showOutlinesStyle () {
|
||||
showOutlinesStyle() {
|
||||
if (!this.form.showOutlines || !this.form.outlineSize) {
|
||||
return ''
|
||||
}
|
||||
@@ -541,7 +549,12 @@ yt-live-chat-text-message-renderer #message * {
|
||||
${!this.form.messageOnNewLine ? '' : `yt-live-chat-text-message-renderer #message {
|
||||
display: block !important;
|
||||
overflow: visible !important;
|
||||
}`}`
|
||||
}`}
|
||||
|
||||
yt-live-chat-text-message-renderer #message .emoji {
|
||||
width: auto !important;
|
||||
height: ${this.form.emoticonSize}px !important;
|
||||
}`
|
||||
},
|
||||
timeStyle() {
|
||||
return common.getTimeStyle(this.form)
|
||||
@@ -655,11 +668,11 @@ yt-live-chat-ticker-sponsor-item-renderer * {
|
||||
try {
|
||||
return mergeConfig(JSON.parse(window.localStorage.stylegenConfig), DEFAULT_CONFIG)
|
||||
} catch {
|
||||
return {...DEFAULT_CONFIG}
|
||||
return { ...DEFAULT_CONFIG }
|
||||
}
|
||||
},
|
||||
resetConfig() {
|
||||
this.form = {...DEFAULT_CONFIG}
|
||||
this.form = { ...DEFAULT_CONFIG }
|
||||
},
|
||||
|
||||
getBgStyleForAuthorType(authorType, color) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form label-width="150px" size="mini">
|
||||
<h3>{{$t('stylegen.avatars')}}</h3>
|
||||
<h3>{{ $t('stylegen.avatars') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
@@ -17,7 +17,7 @@
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<h3>{{$t('stylegen.userNames')}}</h3>
|
||||
<h3>{{ $t('stylegen.userNames') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
@@ -76,7 +76,7 @@
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<h3>{{$t('stylegen.messages')}}</h3>
|
||||
<h3>{{ $t('stylegen.messages') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
@@ -102,9 +102,16 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="$t('stylegen.emoticonSize')">
|
||||
<el-input v-model.number="form.emoticonSize" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<h3>{{$t('stylegen.time')}}</h3>
|
||||
<h3>{{ $t('stylegen.time') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-form-item :label="$t('stylegen.showTime')">
|
||||
<el-switch v-model="form.showTime"></el-switch>
|
||||
@@ -135,7 +142,7 @@
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<h3>{{$t('stylegen.backgrounds')}}</h3>
|
||||
<h3>{{ $t('stylegen.backgrounds') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
@@ -170,7 +177,7 @@
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<h3>{{$t('stylegen.scAndNewMember')}}</h3>
|
||||
<h3>{{ $t('stylegen.scAndNewMember') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
@@ -249,7 +256,7 @@
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<h3>{{$t('stylegen.animation')}}</h3>
|
||||
<h3>{{ $t('stylegen.animation') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="12">
|
||||
@@ -300,7 +307,7 @@ import _ from 'lodash'
|
||||
|
||||
import FontSelect from './FontSelect'
|
||||
import * as common from './common'
|
||||
import {mergeConfig} from '@/utils'
|
||||
import { mergeConfig } from '@/utils'
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
showAvatars: true,
|
||||
@@ -320,6 +327,7 @@ export const DEFAULT_CONFIG = {
|
||||
messageFontSize: 18,
|
||||
messageLineHeight: 0,
|
||||
messageColor: '#000000',
|
||||
emoticonSize: 18,
|
||||
|
||||
showTime: false,
|
||||
timeFont: 'Noto Sans SC',
|
||||
@@ -459,13 +467,18 @@ yt-live-chat-text-message-renderer #message {
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
yt-live-chat-text-message-renderer #message .emoji {
|
||||
width: auto !important;
|
||||
height: ${this.form.emoticonSize}px !important;
|
||||
}
|
||||
|
||||
/* The triangle beside dialog */
|
||||
yt-live-chat-text-message-renderer #message::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: ${this.form.showUserNames ? ((this.form.userNameLineHeight || this.form.userNameFontSize) + 10) : 20}px;
|
||||
left: ${this.form.showAvatars ? (this.form.avatarSize + this.form.avatarSize / 4 - 8) : -8}px;
|
||||
top: ${this.form.showUserNames ? (this.form.userNameLineHeight || this.form.userNameFontSize) + 10 : 20}px;
|
||||
left: ${this.form.showAvatars ? this.form.avatarSize + (this.form.avatarSize / 4) - 8 : -8}px;
|
||||
border: 8px solid transparent;
|
||||
border-right: 18px solid;
|
||||
transform: rotate(35deg);
|
||||
@@ -573,11 +586,11 @@ yt-live-chat-ticker-sponsor-item-renderer * {
|
||||
try {
|
||||
return mergeConfig(JSON.parse(window.localStorage.stylegenLineLikeConfig), DEFAULT_CONFIG)
|
||||
} catch {
|
||||
return {...DEFAULT_CONFIG}
|
||||
return { ...DEFAULT_CONFIG }
|
||||
}
|
||||
},
|
||||
resetConfig() {
|
||||
this.form = {...DEFAULT_CONFIG}
|
||||
this.form = { ...DEFAULT_CONFIG }
|
||||
},
|
||||
|
||||
getBgStyleForAuthorType(authorType, color) {
|
||||
|
||||
@@ -53,7 +53,7 @@ yt-live-chat-membership-item-renderer a {
|
||||
text-decoration: none !important;
|
||||
}`
|
||||
|
||||
export function getImportStyle (allFonts) {
|
||||
export function getImportStyle(allFonts) {
|
||||
let fontsNeedToImport = new Set()
|
||||
for (let font of allFonts) {
|
||||
if (fonts.NETWORK_FONTS.indexOf(font) !== -1) {
|
||||
@@ -67,7 +67,7 @@ export function getImportStyle (allFonts) {
|
||||
return res.join('\n')
|
||||
}
|
||||
|
||||
export function getAvatarStyle (config) {
|
||||
export function getAvatarStyle(config) {
|
||||
return `/* Avatars */
|
||||
yt-live-chat-text-message-renderer #author-photo,
|
||||
yt-live-chat-text-message-renderer #author-photo img,
|
||||
@@ -83,7 +83,7 @@ yt-live-chat-membership-item-renderer #author-photo img {
|
||||
}`
|
||||
}
|
||||
|
||||
export function getTimeStyle (config) {
|
||||
export function getTimeStyle(config) {
|
||||
return `/* Timestamps */
|
||||
yt-live-chat-text-message-renderer #timestamp {
|
||||
display: ${config.showTime ? 'inline' : 'none'} !important;
|
||||
@@ -94,7 +94,7 @@ yt-live-chat-text-message-renderer #timestamp {
|
||||
}`
|
||||
}
|
||||
|
||||
export function getAnimationStyle (config) {
|
||||
export function getAnimationStyle(config) {
|
||||
if (!config.animateIn && !config.animateOut) {
|
||||
return ''
|
||||
}
|
||||
@@ -113,13 +113,13 @@ export function getAnimationStyle (config) {
|
||||
: ` transform: translateX(${config.reverseSlide ? 16 : -16}px);`
|
||||
} }`)
|
||||
curTime += config.fadeInTime
|
||||
keyframes.push(` ${(curTime / totalTime) * 100}% { opacity: 1; transform: none; }`)
|
||||
keyframes.push(` ${curTime / totalTime * 100}% { opacity: 1; transform: none; }`)
|
||||
}
|
||||
if (config.animateOut) {
|
||||
curTime += config.animateOutWaitTime * 1000
|
||||
keyframes.push(` ${(curTime / totalTime) * 100}% { opacity: 1; transform: none; }`)
|
||||
keyframes.push(` ${curTime / totalTime * 100}% { opacity: 1; transform: none; }`)
|
||||
curTime += config.fadeOutTime
|
||||
keyframes.push(` ${(curTime / totalTime) * 100}% { opacity: 0;${!config.slide ? ''
|
||||
keyframes.push(` ${curTime / totalTime * 100}% { opacity: 0;${!config.slide ? ''
|
||||
: ` transform: translateX(${config.reverseSlide ? -16 : 16}px);`
|
||||
} }`)
|
||||
}
|
||||
@@ -136,7 +136,7 @@ yt-live-chat-paid-message-renderer {
|
||||
}`
|
||||
}
|
||||
|
||||
export function cssEscapeStr (str) {
|
||||
export function cssEscapeStr(str) {
|
||||
let res = []
|
||||
for (let char of str) {
|
||||
res.push(cssEscapeChar(char))
|
||||
@@ -144,7 +144,7 @@ export function cssEscapeStr (str) {
|
||||
return res.join('')
|
||||
}
|
||||
|
||||
function cssEscapeChar (char) {
|
||||
function cssEscapeChar(char) {
|
||||
if (!needEscapeChar(char)) {
|
||||
return char
|
||||
}
|
||||
@@ -153,7 +153,7 @@ function cssEscapeChar (char) {
|
||||
return `\\${hexCode} `
|
||||
}
|
||||
|
||||
function needEscapeChar (char) {
|
||||
function needEscapeChar(char) {
|
||||
let code = char.codePointAt(0)
|
||||
if (0x20 <= code && code <= 0x7E) {
|
||||
return char === '"' || char === '\\'
|
||||
|
||||
@@ -11,21 +11,21 @@
|
||||
</el-tabs>
|
||||
|
||||
<el-form label-width="150px" size="mini">
|
||||
<h3>{{$t('stylegen.result')}}</h3>
|
||||
<h3>{{ $t('stylegen.result') }}</h3>
|
||||
<el-card shadow="never">
|
||||
<el-form-item label="CSS">
|
||||
<el-input v-model="inputResult" ref="result" type="textarea" :rows="20"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="copyResult">{{$t('stylegen.copy')}}</el-button>
|
||||
<el-button @click="resetConfig">{{$t('stylegen.resetConfig')}}</el-button>
|
||||
<el-button type="primary" @click="copyResult">{{ $t('stylegen.copy') }}</el-button>
|
||||
<el-button @click="resetConfig">{{ $t('stylegen.resetConfig') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-card>
|
||||
</el-form>
|
||||
</el-col>
|
||||
|
||||
<el-col :sm="24" :md="8">
|
||||
<div :style="{position: 'relative', top: `${exampleTop}px`}">
|
||||
<div :style="{ position: 'relative', top: `${exampleTop}px` }">
|
||||
<el-form inline style="line-height: 40px">
|
||||
<el-form-item :label="$t('stylegen.playAnimation')" style="margin: 0">
|
||||
<el-switch v-model="playAnimation" @change="onPlayAnimationChange"></el-switch>
|
||||
@@ -34,7 +34,7 @@
|
||||
<el-switch v-model="exampleBgLight" :active-text="$t('stylegen.light')" :inactive-text="$t('stylegen.dark')"></el-switch>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div id="example-container" :class="{light: exampleBgLight}">
|
||||
<div id="example-container" :class="{ light: exampleBgLight }">
|
||||
<div id="fakebody">
|
||||
<room ref="room"></room>
|
||||
</div>
|
||||
|
||||
26
frontend/vue.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const API_BASE_URL = 'http://localhost:12450'
|
||||
|
||||
module.exports = {
|
||||
devServer: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: API_BASE_URL,
|
||||
ws: true
|
||||
},
|
||||
'/emoticons': {
|
||||
target: API_BASE_URL
|
||||
}
|
||||
}
|
||||
},
|
||||
chainWebpack: config => {
|
||||
const APP_VERSION = `v${process.env.npm_package_version}`
|
||||
|
||||
config.plugin('define')
|
||||
.tap(args => {
|
||||
let defineMap = args[0]
|
||||
let env = defineMap['process.env']
|
||||
env['APP_VERSION'] = JSON.stringify(APP_VERSION)
|
||||
return args
|
||||
})
|
||||
}
|
||||
}
|
||||
28
main.py
@@ -1,5 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import logging.handlers
|
||||
@@ -12,24 +11,24 @@ import tornado.web
|
||||
import api.chat
|
||||
import api.main
|
||||
import config
|
||||
import models.avatar
|
||||
import models.database
|
||||
import models.translate
|
||||
import services.avatar
|
||||
import services.chat
|
||||
import services.translate
|
||||
import update
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BASE_PATH = os.path.dirname(os.path.realpath(__file__))
|
||||
WEB_ROOT = os.path.join(BASE_PATH, 'frontend', 'dist')
|
||||
LOG_FILE_NAME = os.path.join(BASE_PATH, 'log', 'blivechat.log')
|
||||
|
||||
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),
|
||||
|
||||
(r'/(.*)', api.main.MainHandler, {'path': WEB_ROOT, 'default_filename': 'index.html'})
|
||||
(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'})
|
||||
]
|
||||
|
||||
|
||||
@@ -39,9 +38,9 @@ def main():
|
||||
init_logging(args.debug)
|
||||
config.init()
|
||||
models.database.init(args.debug)
|
||||
models.avatar.init()
|
||||
models.translate.init()
|
||||
api.chat.init()
|
||||
services.avatar.init()
|
||||
services.translate.init()
|
||||
services.chat.init()
|
||||
update.check_update()
|
||||
|
||||
run_server(args.host, args.port, args.debug)
|
||||
@@ -56,9 +55,10 @@ def parse_args():
|
||||
|
||||
|
||||
def init_logging(debug):
|
||||
filename = os.path.join(config.BASE_PATH, 'log', 'blivechat.log')
|
||||
stream_handler = logging.StreamHandler()
|
||||
file_handler = logging.handlers.TimedRotatingFileHandler(
|
||||
LOG_FILE_NAME, encoding='utf-8', when='midnight', backupCount=7, delay=True
|
||||
filename, encoding='utf-8', when='midnight', backupCount=7, delay=True
|
||||
)
|
||||
logging.basicConfig(
|
||||
format='{asctime} {levelname} [{name}]: {message}',
|
||||
@@ -84,7 +84,9 @@ def run_server(host, port, debug):
|
||||
app.listen(
|
||||
port,
|
||||
host,
|
||||
xheaders=cfg.tornado_xheaders
|
||||
xheaders=cfg.tornado_xheaders,
|
||||
max_body_size=1024 * 1024,
|
||||
max_buffer_size=1024 * 1024
|
||||
)
|
||||
except OSError:
|
||||
logger.warning('Address is used %s:%d', host, port)
|
||||
|
||||
11
models/bilibili.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sqlalchemy
|
||||
|
||||
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)
|
||||
@@ -1,6 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import contextlib
|
||||
from typing import *
|
||||
|
||||
import sqlalchemy.ext.declarative
|
||||
@@ -9,27 +7,19 @@ import sqlalchemy.orm
|
||||
import config
|
||||
|
||||
OrmBase = sqlalchemy.ext.declarative.declarative_base()
|
||||
engine = None
|
||||
DbSession: Optional[Type[sqlalchemy.orm.Session]] = None
|
||||
_engine = None
|
||||
_DbSession: Optional[Type[sqlalchemy.orm.Session]] = None
|
||||
|
||||
|
||||
def init(debug):
|
||||
def init(_debug):
|
||||
cfg = config.get_config()
|
||||
global engine, DbSession
|
||||
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)
|
||||
_engine = sqlalchemy.create_engine(cfg.database_url)
|
||||
_DbSession = sqlalchemy.orm.sessionmaker(bind=_engine)
|
||||
|
||||
OrmBase.metadata.create_all(engine)
|
||||
OrmBase.metadata.create_all(_engine)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def get_session():
|
||||
session = DbSession()
|
||||
try:
|
||||
yield session
|
||||
except:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
return _DbSession()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
aiohttp==3.7.4
|
||||
sqlalchemy==1.3.13
|
||||
tornado==6.0.2
|
||||
Brotli==1.0.9
|
||||
pycryptodome==3.10.1
|
||||
sqlalchemy==1.4.31
|
||||
tornado==6.1.0
|
||||
|
||||
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 299 KiB |
@@ -1,5 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
@@ -11,7 +10,9 @@ import sqlalchemy
|
||||
import sqlalchemy.exc
|
||||
|
||||
import config
|
||||
import models.bilibili as bl_models
|
||||
import models.database
|
||||
import utils.request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,13 +20,12 @@ logger = logging.getLogger(__name__)
|
||||
DEFAULT_AVATAR_URL = '//static.hdslb.com/images/member/noface.gif'
|
||||
|
||||
_main_event_loop = asyncio.get_event_loop()
|
||||
_http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
|
||||
# user_id -> avatar_url
|
||||
_avatar_url_cache: Dict[int, str] = {}
|
||||
# 正在获取头像的Future,user_id -> Future
|
||||
_uid_fetch_future_map: Dict[int, asyncio.Future] = {}
|
||||
# 正在获取头像的user_id队列
|
||||
_uid_queue_to_fetch = None
|
||||
_uid_queue_to_fetch: Optional[asyncio.Queue] = None
|
||||
# 上次被B站ban时间
|
||||
_last_fetch_banned_time: Optional[datetime.datetime] = None
|
||||
|
||||
@@ -67,7 +67,9 @@ def get_avatar_url_from_database(user_id) -> Awaitable[Optional[str]]:
|
||||
def _do_get_avatar_url_from_database(user_id):
|
||||
try:
|
||||
with models.database.get_session() as session:
|
||||
user = session.query(BilibiliUser).filter(BilibiliUser.uid == user_id).one_or_none()
|
||||
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
|
||||
@@ -130,7 +132,7 @@ async def _get_avatar_url_from_web_consumer():
|
||||
# 限制频率,防止被B站ban
|
||||
cfg = config.get_config()
|
||||
await asyncio.sleep(cfg.fetch_avatar_interval)
|
||||
except Exception:
|
||||
except Exception: # noqa
|
||||
logger.exception('_get_avatar_url_from_web_consumer error:')
|
||||
|
||||
|
||||
@@ -145,8 +147,9 @@ async def _get_avatar_url_from_web_coroutine(user_id, future):
|
||||
|
||||
async def _do_get_avatar_url_from_web(user_id):
|
||||
try:
|
||||
async with _http_session.get('https://api.bilibili.com/x/space/acc/info',
|
||||
params={'mid': user_id}) as r:
|
||||
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:
|
||||
@@ -191,24 +194,19 @@ def _update_avatar_cache_in_memory(user_id, avatar_url):
|
||||
def _update_avatar_cache_in_database(user_id, avatar_url):
|
||||
try:
|
||||
with models.database.get_session() as session:
|
||||
user = session.query(BilibiliUser).filter(BilibiliUser.uid == user_id).one_or_none()
|
||||
user = session.query(bl_models.BilibiliUser).filter(
|
||||
bl_models.BilibiliUser.uid == user_id
|
||||
).one_or_none()
|
||||
if user is None:
|
||||
user = BilibiliUser(uid=user_id, avatar_url=avatar_url,
|
||||
update_time=datetime.datetime.now())
|
||||
user = bl_models.BilibiliUser(
|
||||
uid=user_id
|
||||
)
|
||||
session.add(user)
|
||||
else:
|
||||
user.avatar_url = avatar_url
|
||||
user.update_time = datetime.datetime.now()
|
||||
user.avatar_url = avatar_url
|
||||
user.update_time = datetime.datetime.now()
|
||||
session.commit()
|
||||
except (sqlalchemy.exc.OperationalError, sqlalchemy.exc.IntegrityError):
|
||||
# SQLite会锁整个文件,忽略就行,另外还有多线程导致ID重复的问题
|
||||
pass
|
||||
except sqlalchemy.exc.SQLAlchemyError:
|
||||
logger.exception('_update_avatar_cache_in_database failed:')
|
||||
|
||||
|
||||
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)
|
||||
456
services/chat.py
Normal file
@@ -0,0 +1,456 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import *
|
||||
|
||||
import api.chat
|
||||
import blivedm.blivedm as blivedm
|
||||
import config
|
||||
import services.avatar
|
||||
import services.translate
|
||||
import utils.request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 到B站的连接管理
|
||||
_live_client_manager: Optional['LiveClientManager'] = None
|
||||
# 到客户端的连接管理
|
||||
client_room_manager: Optional['ClientRoomManager'] = None
|
||||
# 直播消息处理器
|
||||
_live_msg_handler: Optional['LiveMsgHandler'] = None
|
||||
|
||||
|
||||
def init():
|
||||
global _live_client_manager, client_room_manager, _live_msg_handler
|
||||
_live_client_manager = LiveClientManager()
|
||||
client_room_manager = ClientRoomManager()
|
||||
_live_msg_handler = LiveMsgHandler()
|
||||
|
||||
|
||||
class LiveClientManager:
|
||||
"""管理到B站的连接"""
|
||||
def __init__(self):
|
||||
self._live_clients: Dict[int, LiveClient] = {}
|
||||
|
||||
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 _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)
|
||||
return
|
||||
logger.info('room=%d (%d) live client init succeeded', live_client.tmp_room_id, live_client.room_id)
|
||||
live_client.start()
|
||||
|
||||
def del_live_client(self, room_id):
|
||||
live_client = self._live_clients.pop(room_id, 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)
|
||||
|
||||
|
||||
class LiveClient(blivedm.BLiveClient):
|
||||
HEARTBEAT_INTERVAL = 10
|
||||
|
||||
def __init__(self, room_id):
|
||||
super().__init__(room_id, session=utils.request.http_session, heartbeat_interval=self.HEARTBEAT_INTERVAL)
|
||||
|
||||
@property
|
||||
def tmp_room_id(self):
|
||||
"""初始化参数传入的房间ID,room_id可能改变,这个不会变"""
|
||||
return self._tmp_room_id
|
||||
|
||||
async def init_room(self):
|
||||
await super().init_room()
|
||||
return True
|
||||
|
||||
|
||||
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] = {}
|
||||
|
||||
def add_client(self, room_id, client: 'api.chat.ChatHandler'):
|
||||
room = self._get_or_add_room(room_id)
|
||||
room.add_client(client)
|
||||
|
||||
self._clear_delay_del_timer(room_id)
|
||||
|
||||
def del_client(self, room_id, client: 'api.chat.ChatHandler'):
|
||||
room = self.get_room(room_id)
|
||||
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)
|
||||
|
||||
def get_room(self, room_id):
|
||||
return self._rooms.get(room_id, None)
|
||||
|
||||
def _get_or_add_room(self, room_id):
|
||||
room = self._rooms.get(room_id, 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))
|
||||
|
||||
_live_client_manager.add_live_client(room_id)
|
||||
return room
|
||||
|
||||
def del_room(self, room_id):
|
||||
self._clear_delay_del_timer(room_id)
|
||||
|
||||
room = self._rooms.pop(room_id, None)
|
||||
if room is None:
|
||||
return
|
||||
logger.info('room=%d removing client room', room_id)
|
||||
room.clear_clients()
|
||||
logger.info('room=%d client room removed, %d client rooms', room_id, len(self._rooms))
|
||||
|
||||
_live_client_manager.del_live_client(room_id)
|
||||
|
||||
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 _clear_delay_del_timer(self, room_id):
|
||||
timer_handle = self._delay_del_timer_handles.pop(room_id, 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)
|
||||
|
||||
|
||||
class ClientRoom:
|
||||
def __init__(self, room_id):
|
||||
self._room_id = room_id
|
||||
self._clients: List[api.chat.ChatHandler] = []
|
||||
self._auto_translate_count = 0
|
||||
|
||||
@property
|
||||
def room_id(self):
|
||||
return self._room_id
|
||||
|
||||
@property
|
||||
def client_count(self):
|
||||
return len(self._clients)
|
||||
|
||||
@property
|
||||
def need_translate(self):
|
||||
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)
|
||||
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,
|
||||
self.client_count)
|
||||
|
||||
def del_client(self, client: 'api.chat.ChatHandler'):
|
||||
client.close()
|
||||
try:
|
||||
self._clients.remove(client)
|
||||
except ValueError:
|
||||
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,
|
||||
self.client_count)
|
||||
|
||||
def clear_clients(self):
|
||||
logger.info('room=%d clearing %d clients', self._room_id, self.client_count)
|
||||
for client in self._clients:
|
||||
client.close()
|
||||
self._clients.clear()
|
||||
self._auto_translate_count = 0
|
||||
|
||||
def send_cmd_data(self, cmd, data):
|
||||
body = api.chat.make_message_body(cmd, data)
|
||||
for client in self._clients:
|
||||
client.send_body_no_raise(body)
|
||||
|
||||
def send_cmd_data_if(self, filterer: Callable[['api.chat.ChatHandler'], bool], cmd, data):
|
||||
body = api.chat.make_message_body(cmd, data)
|
||||
for client in filter(filterer, self._clients):
|
||||
client.send_body_no_raise(body)
|
||||
|
||||
|
||||
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]
|
||||
else:
|
||||
medal_level = 0
|
||||
medal_room_id = 0
|
||||
|
||||
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)
|
||||
if room is None:
|
||||
return
|
||||
|
||||
if message.uid == client.room_owner_uid:
|
||||
author_type = 3 # 主播
|
||||
elif message.admin:
|
||||
author_type = 2 # 房管
|
||||
elif message.privilege_type != 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.emoticon_options_dict['url'],
|
||||
)
|
||||
else:
|
||||
content_type = api.chat.ContentType.TEXT
|
||||
content_type_params = None
|
||||
|
||||
need_translate = self._need_translate(message.msg, room)
|
||||
if need_translate:
|
||||
translation = services.translate.get_translation_from_cache(message.msg)
|
||||
if translation is None:
|
||||
# 没有缓存,需要后面异步翻译后通知
|
||||
translation = ''
|
||||
else:
|
||||
need_translate = False
|
||||
else:
|
||||
translation = ''
|
||||
|
||||
msg_id = uuid.uuid4().hex
|
||||
room.send_cmd_data(api.chat.Command.ADD_TEXT, api.chat.make_text_message_data(
|
||||
avatar_url=avatar_url,
|
||||
timestamp=int(message.timestamp / 1000),
|
||||
author_name=message.uname,
|
||||
author_type=author_type,
|
||||
content=message.msg,
|
||||
privilege_type=message.privilege_type,
|
||||
is_gift_danmaku=bool(message.msg_type),
|
||||
author_level=message.user_level,
|
||||
is_newbie=message.urank < 10000,
|
||||
is_mobile_verified=bool(message.mobile_verify),
|
||||
medal_level=0 if message.medal_room_id != client.room_id else message.medal_level,
|
||||
id_=msg_id,
|
||||
translation=translation,
|
||||
content_type=content_type,
|
||||
content_type_params=content_type_params,
|
||||
))
|
||||
|
||||
if need_translate:
|
||||
await self._translate_and_response(message.msg, room.room_id, msg_id)
|
||||
|
||||
async def _on_gift(self, client: LiveClient, message: blivedm.GiftMessage):
|
||||
avatar_url = services.avatar.process_avatar_url(message.face)
|
||||
# 服务器白给的头像URL,直接缓存
|
||||
services.avatar.update_avatar_cache(message.uid, avatar_url)
|
||||
|
||||
# 丢人
|
||||
if message.coin_type != 'gold':
|
||||
return
|
||||
|
||||
room = client_room_manager.get_room(client.tmp_room_id)
|
||||
if room is None:
|
||||
return
|
||||
|
||||
room.send_cmd_data(api.chat.Command.ADD_GIFT, {
|
||||
'id': uuid.uuid4().hex,
|
||||
'avatarUrl': avatar_url,
|
||||
'timestamp': message.timestamp,
|
||||
'authorName': message.uname,
|
||||
'totalCoin': message.total_coin,
|
||||
'giftName': message.gift_name,
|
||||
'num': message.num
|
||||
})
|
||||
|
||||
async def _on_buy_guard(self, client: LiveClient, message: blivedm.GuardBuyMessage):
|
||||
asyncio.ensure_future(self.__on_buy_guard(client, message))
|
||||
|
||||
@staticmethod
|
||||
async def __on_buy_guard(client: LiveClient, message: blivedm.GuardBuyMessage):
|
||||
# 先异步调用再获取房间,因为返回时房间可能已经不存在了
|
||||
avatar_url = await services.avatar.get_avatar_url(message.uid)
|
||||
|
||||
room = client_room_manager.get_room(client.tmp_room_id)
|
||||
if room is None:
|
||||
return
|
||||
|
||||
room.send_cmd_data(api.chat.Command.ADD_MEMBER, {
|
||||
'id': uuid.uuid4().hex,
|
||||
'avatarUrl': avatar_url,
|
||||
'timestamp': message.start_time,
|
||||
'authorName': message.username,
|
||||
'privilegeType': message.guard_level
|
||||
})
|
||||
|
||||
async def _on_super_chat(self, client: LiveClient, message: blivedm.SuperChatMessage):
|
||||
avatar_url = services.avatar.process_avatar_url(message.face)
|
||||
# 服务器白给的头像URL,直接缓存
|
||||
services.avatar.update_avatar_cache(message.uid, avatar_url)
|
||||
|
||||
room = client_room_manager.get_room(client.tmp_room_id)
|
||||
if room is None:
|
||||
return
|
||||
|
||||
need_translate = self._need_translate(message.message, room)
|
||||
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.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.price,
|
||||
'content': message.message,
|
||||
'translation': translation
|
||||
})
|
||||
|
||||
if need_translate:
|
||||
asyncio.ensure_future(self._translate_and_response(message.message, room.room_id, msg_id))
|
||||
|
||||
async def _on_super_chat_delete(self, client: LiveClient, message: blivedm.SuperChatDeleteMessage):
|
||||
room = client_room_manager.get_room(client.tmp_room_id)
|
||||
if room is None:
|
||||
return
|
||||
|
||||
room.send_cmd_data(api.chat.Command.ADD_SUPER_CHAT, {
|
||||
'ids': list(map(str, message.ids))
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def _need_translate(text, room: ClientRoom):
|
||||
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 services.translate.need_translate(text)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _translate_and_response(text, room_id, msg_id):
|
||||
translation = await services.translate.translate(text)
|
||||
if translation is None:
|
||||
return
|
||||
|
||||
room = client_room_manager.get_room(room_id)
|
||||
if room is None:
|
||||
return
|
||||
|
||||
room.send_cmd_data_if(
|
||||
lambda client: client.auto_translate,
|
||||
api.chat.Command.UPDATE_TRANSLATION,
|
||||
api.chat.make_translation_message_data(
|
||||
msg_id,
|
||||
translation
|
||||
)
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import functools
|
||||
import hashlib
|
||||
@@ -11,9 +11,12 @@ import random
|
||||
import re
|
||||
from typing import *
|
||||
|
||||
import Crypto.Cipher.AES as cry_aes # noqa
|
||||
import Crypto.Util.Padding as cry_pad # noqa
|
||||
import aiohttp
|
||||
|
||||
import config
|
||||
import utils.request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,8 +25,6 @@ NO_TRANSLATE_TEXTS = {
|
||||
'强', '余裕', '余裕余裕', '大丈夫', '再放送', '放送事故', '清楚', '清楚清楚'
|
||||
}
|
||||
|
||||
_main_event_loop = asyncio.get_event_loop()
|
||||
_http_session = None
|
||||
_translate_providers: List['TranslateProvider'] = []
|
||||
# text -> res
|
||||
_translate_cache: Dict[str, str] = {}
|
||||
@@ -36,9 +37,6 @@ def init():
|
||||
|
||||
|
||||
async def _do_init():
|
||||
global _http_session
|
||||
_http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
|
||||
|
||||
cfg = config.get_config()
|
||||
if not cfg.enable_translate:
|
||||
return
|
||||
@@ -104,7 +102,7 @@ def translate(text) -> Awaitable[Optional[str]]:
|
||||
if future is not None:
|
||||
return future
|
||||
# 否则创建一个翻译任务
|
||||
future = _main_event_loop.create_future()
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
|
||||
# 查缓存
|
||||
res = _translate_cache.get(key, None)
|
||||
@@ -139,7 +137,7 @@ def _on_translate_done(key, future):
|
||||
# 缓存
|
||||
try:
|
||||
res = future.result()
|
||||
except Exception:
|
||||
except Exception: # noqa
|
||||
return
|
||||
if res is None:
|
||||
return
|
||||
@@ -196,7 +194,7 @@ class FlowControlTranslateProvider(TranslateProvider):
|
||||
asyncio.ensure_future(self._translate_coroutine(text, future))
|
||||
# 频率限制
|
||||
await asyncio.sleep(self._query_interval)
|
||||
except Exception:
|
||||
except Exception: # noqa
|
||||
logger.exception('FlowControlTranslateProvider error:')
|
||||
|
||||
async def _translate_coroutine(self, text, future):
|
||||
@@ -217,9 +215,11 @@ class TencentTranslateFree(FlowControlTranslateProvider):
|
||||
self._source_language = source_language
|
||||
self._target_language = target_language
|
||||
|
||||
self._qtv = ''
|
||||
self._qtk = ''
|
||||
self._server_time_delta = 0
|
||||
self._uc_key = self._uc_iv = ''
|
||||
self._qtv = self._qtk = ''
|
||||
self._reinit_future = None
|
||||
|
||||
# 连续失败的次数
|
||||
self._fail_count = 0
|
||||
|
||||
@@ -233,19 +233,49 @@ class TencentTranslateFree(FlowControlTranslateProvider):
|
||||
|
||||
async def _do_init(self):
|
||||
try:
|
||||
async with _http_session.get('https://fanyi.qq.com/') as r:
|
||||
async with utils.request.http_session.get('https://fanyi.qq.com/') as r:
|
||||
if r.status != 200:
|
||||
logger.warning('TencentTranslateFree init request failed: status=%d %s', r.status, r.reason)
|
||||
return False
|
||||
html = await r.text()
|
||||
|
||||
m = re.search(r"""\breauthuri\s*=\s*['"](.+?)['"]""", html)
|
||||
if m is None:
|
||||
logger.exception('TencentTranslateFree init failed: reauthuri not found')
|
||||
return False
|
||||
reauthuri = m[1]
|
||||
try:
|
||||
server_time = r.headers['Date']
|
||||
server_time = datetime.datetime.strptime(server_time, '%a, %d %b %Y %H:%M:%S GMT')
|
||||
server_time = server_time.replace(tzinfo=datetime.timezone.utc).timestamp()
|
||||
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):
|
||||
logger.exception('TencentTranslateFree init error:')
|
||||
return False
|
||||
|
||||
async with _http_session.post('https://fanyi.qq.com/api/' + reauthuri) as r:
|
||||
# 获取token URL
|
||||
m = re.search(r"""\breauthuri\s*=\s*['"](.+?)['"]""", html)
|
||||
if m is None:
|
||||
logger.exception('TencentTranslateFree init failed: reauthuri not found')
|
||||
return False
|
||||
reauthuri = m[1]
|
||||
|
||||
# 获取验证用的key、iv
|
||||
m = re.search(r"""\s*=\s*['"]((?:\w+\|\w+-)+\w+\|\w+)['"]""", html)
|
||||
if m is None:
|
||||
logger.exception('TencentTranslateFree init failed: initial global variables not found')
|
||||
return False
|
||||
uc_key = None
|
||||
uc_iv = None
|
||||
for item in m[1].split('-'):
|
||||
key, _, value = item.partition('|')
|
||||
if key == 'a137':
|
||||
uc_key = value
|
||||
elif key == 'E74':
|
||||
uc_iv = value
|
||||
if uc_key is not None and uc_iv is not None:
|
||||
break
|
||||
|
||||
# 获取token
|
||||
try:
|
||||
async with utils.request.http_session.post('https://fanyi.qq.com/api/' + reauthuri) as r:
|
||||
if r.status != 200:
|
||||
logger.warning('TencentTranslateFree init request failed: reauthuri=%s, status=%d %s',
|
||||
reauthuri, r.status, r.reason)
|
||||
@@ -264,6 +294,8 @@ class TencentTranslateFree(FlowControlTranslateProvider):
|
||||
logger.warning('TencentTranslateFree init failed: qtk not found')
|
||||
return False
|
||||
|
||||
self._uc_key = uc_key
|
||||
self._uc_iv = uc_iv
|
||||
self._qtv = qtv
|
||||
self._qtk = qtk
|
||||
return True
|
||||
@@ -279,7 +311,7 @@ class TencentTranslateFree(FlowControlTranslateProvider):
|
||||
|
||||
@property
|
||||
def is_available(self):
|
||||
return self._qtv != '' and self._qtk != '' and super().is_available
|
||||
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:
|
||||
@@ -296,10 +328,11 @@ class TencentTranslateFree(FlowControlTranslateProvider):
|
||||
|
||||
async def _do_translate(self, text):
|
||||
try:
|
||||
async with _http_session.post(
|
||||
async with utils.request.http_session.post(
|
||||
'https://fanyi.qq.com/api/translate',
|
||||
headers={
|
||||
'Referer': 'https://fanyi.qq.com/'
|
||||
'Referer': 'https://fanyi.qq.com/',
|
||||
'uc': self._get_uc()
|
||||
},
|
||||
data={
|
||||
'source': self._source_language,
|
||||
@@ -312,6 +345,7 @@ class TencentTranslateFree(FlowControlTranslateProvider):
|
||||
if r.status != 200:
|
||||
logger.warning('TencentTranslateFree request failed: status=%d %s', r.status, r.reason)
|
||||
return None
|
||||
self._update_uc_key(r)
|
||||
data = await r.json()
|
||||
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
||||
return None
|
||||
@@ -325,15 +359,59 @@ class TencentTranslateFree(FlowControlTranslateProvider):
|
||||
return None
|
||||
return res
|
||||
|
||||
def _get_uc(self):
|
||||
user_actions = self._gen_user_actions()
|
||||
cur_timestamp = str(int(datetime.datetime.now().timestamp() * 1000))
|
||||
server_time_delta = str(self._server_time_delta)
|
||||
uc = '|'.join([user_actions, cur_timestamp, server_time_delta])
|
||||
|
||||
aes = cry_aes.new(self._uc_key.encode('utf-8'), cry_aes.MODE_CBC, self._uc_iv.encode('utf-8'))
|
||||
uc = cry_pad.pad(uc.encode('utf-8'), aes.block_size, 'pkcs7')
|
||||
uc = aes.encrypt(uc)
|
||||
uc = base64.b64encode(uc).decode('utf-8')
|
||||
return uc
|
||||
|
||||
@staticmethod
|
||||
def _gen_user_actions():
|
||||
# 1:点击翻译;2:源输入框聚焦或失去焦点;3:点击源语言列表;4:点击交换语言;5:点击目标语言列表;6:源输入框输入、粘贴
|
||||
user_actions = []
|
||||
if random.randint(1, 5) == 1:
|
||||
for i in range(random.randint(1, 2)):
|
||||
user_actions.append('2')
|
||||
user_actions.append('6')
|
||||
for i in range(random.randint(0, 6)):
|
||||
user_actions.append(random.choice('26'))
|
||||
if random.randint(1, 5) == 1:
|
||||
user_actions.append('1')
|
||||
return ''.join(user_actions)
|
||||
|
||||
def _update_uc_key(self, r):
|
||||
try:
|
||||
hf_f = r.headers['f']
|
||||
hf_ts = int(r.headers['ts'])
|
||||
except (KeyError, ValueError):
|
||||
return
|
||||
|
||||
cur_timestamp = int(datetime.datetime.now().timestamp() * 1000)
|
||||
hf_f = base64.b64decode(hf_f.encode('utf-8')).decode('utf-8')
|
||||
pos = int(hf_f[72: 72 + 4])
|
||||
uc_key = hf_f[pos: pos + 16]
|
||||
uc_iv = hf_f[pos + 16: pos + 16 + 16]
|
||||
|
||||
self._server_time_delta = cur_timestamp - hf_ts
|
||||
self._uc_key = uc_key
|
||||
self._uc_iv = uc_iv
|
||||
|
||||
def _on_fail(self):
|
||||
self._fail_count += 1
|
||||
# 目前没有测试出被ban的情况,为了可靠性,连续失败20次时冷却直到下次重新init
|
||||
if self._fail_count >= 20:
|
||||
# 为了可靠性,连续失败10次时冷却直到下次重新init
|
||||
if self._fail_count >= 10:
|
||||
self._cool_down()
|
||||
|
||||
def _cool_down(self):
|
||||
logger.info('TencentTranslateFree is cooling down')
|
||||
# 下次_do_init后恢复
|
||||
self._uc_key = self._uc_iv = ''
|
||||
self._qtv = self._qtk = ''
|
||||
self._fail_count = 0
|
||||
|
||||
@@ -344,7 +422,7 @@ class BilibiliTranslateFree(FlowControlTranslateProvider):
|
||||
|
||||
async def _do_translate(self, text):
|
||||
try:
|
||||
async with _http_session.get(
|
||||
async with utils.request.http_session.get(
|
||||
'https://api.live.bilibili.com/av/v1/SuperChat/messageTranslate',
|
||||
params={
|
||||
'room_id': '21396545',
|
||||
@@ -444,7 +522,7 @@ class TencentTranslate(FlowControlTranslateProvider):
|
||||
'X-TC-Region': self._region
|
||||
}
|
||||
|
||||
return _http_session.post('https://tmt.tencentcloudapi.com/', headers=headers, data=body_bytes)
|
||||
return utils.request.http_session.post('https://tmt.tencentcloudapi.com/', headers=headers, data=body_bytes)
|
||||
|
||||
def _on_fail(self, code):
|
||||
if self._cool_down_timer_handle is not None:
|
||||
@@ -492,7 +570,7 @@ class BaiduTranslate(FlowControlTranslateProvider):
|
||||
|
||||
async def _do_translate(self, text):
|
||||
try:
|
||||
async with _http_session.post(
|
||||
async with utils.request.http_session.post(
|
||||
'https://fanyi-api.baidu.com/api/trans/vip/translate',
|
||||
data=self._add_sign({
|
||||
'q': text,
|
||||
24
update.py
@@ -1,10 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import asyncio
|
||||
|
||||
import aiohttp
|
||||
|
||||
VERSION = 'v1.5.2'
|
||||
import utils.request
|
||||
|
||||
VERSION = 'v1.6.0'
|
||||
|
||||
|
||||
def check_update():
|
||||
@@ -13,15 +14,16 @@ def check_update():
|
||||
|
||||
async def _do_check_update():
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
||||
async with session.get('https://api.github.com/repos/xfgryujk/blivechat/releases/latest') as r:
|
||||
data = await r.json()
|
||||
if data['name'] != VERSION:
|
||||
print('---------------------------------------------')
|
||||
print('New version available:', data['name'])
|
||||
print(data['body'])
|
||||
print('Download:', data['html_url'])
|
||||
print('---------------------------------------------')
|
||||
async with utils.request.http_session.get(
|
||||
'https://api.github.com/repos/xfgryujk/blivechat/releases/latest'
|
||||
) as r:
|
||||
data = await r.json()
|
||||
if data['name'] != VERSION:
|
||||
print('---------------------------------------------')
|
||||
print('New version available:', data['name'])
|
||||
print(data['body'])
|
||||
print('Download:', data['html_url'])
|
||||
print('---------------------------------------------')
|
||||
except aiohttp.ClientConnectionError:
|
||||
print('Failed to check update: connection failed')
|
||||
except asyncio.TimeoutError:
|
||||
|
||||
4
utils/request.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import aiohttp
|
||||
|
||||
http_session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10))
|
||||