Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b73a4e3748 | ||
|
|
102746d0b0 | ||
|
|
2d237af6fd | ||
|
|
4030ff43cd | ||
|
|
092cc8d9ab | ||
|
|
2d7106ae93 | ||
|
|
d5b885aab4 | ||
|
|
34f0effb6a | ||
|
|
d3e9300fa4 | ||
|
|
0c9560f8ca | ||
|
|
1c5df01fbc | ||
|
|
49cdc56cff | ||
|
|
00e1f31f84 | ||
|
|
b7e06652b4 | ||
|
|
0cf49ee042 | ||
|
|
7d610ecfdc | ||
|
|
c8e4278c39 | ||
|
|
07b47a26ca | ||
|
|
8d55331e6c | ||
|
|
cae06858fc | ||
|
|
8d4e8e6f35 | ||
|
|
8d40f9f9e5 | ||
|
|
e93f6b2383 | ||
|
|
4c2e216191 | ||
|
|
2a35541dc5 | ||
|
|
a927282e77 | ||
|
|
a7faac5425 | ||
|
|
a22496c355 | ||
|
|
20ec58b965 | ||
|
|
7453d7e890 | ||
|
|
1991e33b9d | ||
|
|
f429a6a03d | ||
|
|
4b98c56965 | ||
|
|
eb43a37ae5 | ||
|
|
1cf6d88460 | ||
|
|
9ddbebbd7e | ||
|
|
ca7f27d536 | ||
|
|
98184e6732 | ||
|
|
3b2616c278 | ||
|
|
9565c52e23 | ||
|
|
bb1acaecd6 | ||
|
|
dcbb90a7bd | ||
|
|
c46542bee0 | ||
|
|
709ec0585e | ||
|
|
a9c6709080 | ||
|
|
938f758920 | ||
|
|
ce4974be94 | ||
|
|
040bad0eba |
1
.gitignore
vendored
@@ -105,3 +105,4 @@ venv.bak/
|
||||
|
||||
|
||||
.idea/
|
||||
data/database.db
|
||||
|
||||
27
README.md
@@ -1,21 +1,22 @@
|
||||
# blivechat
|
||||
用于OBS的仿YouTube风格的bilibili直播聊天层
|
||||
用于OBS的仿YouTube风格的bilibili直播评论栏
|
||||
|
||||
最近喜欢看VTuber,想为此写些程序,于是有了这个东西。~~写到一半发现有类似项目了:[bilibili-live-chat](https://github.com/Tsuk1ko/bilibili-live-chat)、[BiliChat](https://github.com/3Shain/BiliChat)。~~ 本项目就当做练手吧,而且对YouTube的模仿程度更高
|
||||
最近喜欢看VTuber,想为此写些程序,于是有了这个东西。~~写到一半发现有类似项目了:[bilibili-live-chat](https://github.com/Tsuk1ko/bilibili-live-chat)、[BiliChat](https://github.com/3Shain/BiliChat)~~
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## 特性
|
||||
* 兼容YouTube直播chat的样式
|
||||
* 金瓜子礼物模仿Super Chat显示
|
||||
* 兼容YouTube直播评论栏的样式
|
||||
* 金瓜子礼物模仿醒目留言显示
|
||||
* 高亮舰队、房管、主播的用户名
|
||||
* 支持屏蔽弹幕、限制最大速度等设置
|
||||
* 支持屏蔽弹幕、合并相似弹幕等设置
|
||||
* 自带样式生成器
|
||||
* 支持自动翻译弹幕、醒目留言到日语
|
||||
|
||||
## 使用方法
|
||||
### 发布版
|
||||
### 本地使用
|
||||
1. 下载[发布版](https://github.com/xfgryujk/blivechat/releases)(仅提供x64 Windows版)
|
||||
2. 双击`blivechat.exe`运行服务器,或者用命令行可以指定host和端口号:
|
||||
```bat
|
||||
@@ -23,7 +24,13 @@
|
||||
```
|
||||
3. 用浏览器打开[http://localhost:12450](http://localhost:12450),输入房间ID,保存配置,复制房间URL
|
||||
4. 用样式生成器生成样式,复制CSS
|
||||
5. 在OBS中添加浏览器源,输入URL和自定义CSS,或者可以在首页的样式设置里输入CSS
|
||||
5. 在OBS中添加浏览器源,输入URL和自定义CSS
|
||||
|
||||
### 公共服务器
|
||||
请优先在本地使用,使用公共服务器会有更大的弹幕延迟,而且服务器故障时可能出现直播事故
|
||||
|
||||
* [第三方公共服务器](http://chat.bilisc.com/)
|
||||
* [仅样式生成器](https://style.vtbs.moe/)
|
||||
|
||||
### 源代码版
|
||||
1. 编译前端(需要安装Node.js和npm):
|
||||
@@ -41,12 +48,10 @@
|
||||
```sh
|
||||
python3 main.py --host 127.0.0.1 --port 12450
|
||||
```
|
||||
3. 用浏览器打开[http://localhost:12450](http://localhost:12450),输入房间ID,保存配置,复制房间URL
|
||||
4. 用样式生成器生成样式,复制CSS
|
||||
5. 在OBS中添加浏览器源,输入URL和自定义CSS,或者可以在首页的样式设置里输入CSS
|
||||
3. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
|
||||
|
||||
### Docker
|
||||
1. ```sh
|
||||
docker run -d -p 12450:12450 xfgryujk/blivechat:latest
|
||||
```
|
||||
2. 以下略
|
||||
2. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
|
||||
|
||||
@@ -19,7 +19,10 @@ class ApiHandler(tornado.web.RequestHandler):
|
||||
|
||||
def prepare(self):
|
||||
if self.request.headers.get('Content-Type', '').startswith('application/json'):
|
||||
self.json_args = json.loads(self.request.body)
|
||||
try:
|
||||
self.json_args = json.loads(self.request.body)
|
||||
except json.JSONDecodeError:
|
||||
self.json_args = None
|
||||
else:
|
||||
self.json_args = None
|
||||
|
||||
463
api/chat.py
Normal file
@@ -0,0 +1,463 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import asyncio
|
||||
import enum
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from typing import *
|
||||
|
||||
import aiohttp
|
||||
import tornado.websocket
|
||||
|
||||
import blivedm.blivedm as blivedm
|
||||
import config
|
||||
import models.avatar
|
||||
import models.translate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(enum.IntEnum):
|
||||
HEARTBEAT = 0
|
||||
JOIN_ROOM = 1
|
||||
ADD_TEXT = 2
|
||||
ADD_GIFT = 3
|
||||
ADD_MEMBER = 4
|
||||
ADD_SUPER_CHAT = 5
|
||||
DEL_SUPER_CHAT = 6
|
||||
UPDATE_TRANSLATION = 7
|
||||
|
||||
|
||||
_http_session = aiohttp.ClientSession()
|
||||
|
||||
room_manager: Optional['RoomManager'] = None
|
||||
|
||||
|
||||
def init():
|
||||
global room_manager
|
||||
room_manager = RoomManager()
|
||||
|
||||
|
||||
class Room(blivedm.BLiveClient):
|
||||
# 重新定义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'], None, 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=10)
|
||||
self.clients: List['ChatHandler'] = []
|
||||
self.auto_translate_count = 0
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
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, [
|
||||
# 0: avatarUrl
|
||||
await models.avatar.get_avatar_url(danmaku.uid),
|
||||
# 1: timestamp
|
||||
danmaku.timestamp,
|
||||
# 2: authorName
|
||||
danmaku.uname,
|
||||
# 3: authorType
|
||||
author_type,
|
||||
# 4: content
|
||||
danmaku.msg,
|
||||
# 5: privilegeType
|
||||
danmaku.privilege_type,
|
||||
# 6: isGiftDanmaku
|
||||
1 if danmaku.msg_type else 0,
|
||||
# 7: authorLevel
|
||||
danmaku.user_level,
|
||||
# 8: isNewbie
|
||||
1 if danmaku.urank < 10000 else 0,
|
||||
# 9: isMobileVerified
|
||||
1 if danmaku.mobile_verify else 0,
|
||||
# 10: medalLevel
|
||||
0 if danmaku.room_id != self.room_id else danmaku.medal_level,
|
||||
# 11: id
|
||||
id_,
|
||||
# 12: translation
|
||||
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
|
||||
})
|
||||
|
||||
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):
|
||||
return (
|
||||
config.get_config().enable_translate
|
||||
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,
|
||||
[
|
||||
# 0: id
|
||||
msg_id,
|
||||
# 1: 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[room_id]
|
||||
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
|
||||
|
||||
if client.application.settings['debug']:
|
||||
await client.send_test_message()
|
||||
|
||||
def del_client(self, room_id, client: 'ChatHandler'):
|
||||
if room_id not in self._rooms:
|
||||
return
|
||||
room = self._rooms[room_id]
|
||||
room.clients.remove(client)
|
||||
logger.info('%d clients in room %s', len(room.clients), room_id)
|
||||
if client.auto_translate:
|
||||
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)
|
||||
room = Room(room_id)
|
||||
self._rooms[room_id] = room
|
||||
if await room.init_room():
|
||||
room.start()
|
||||
return True
|
||||
else:
|
||||
self._del_room(room_id)
|
||||
return False
|
||||
|
||||
def _del_room(self, room_id):
|
||||
if room_id not in self._rooms:
|
||||
return
|
||||
logger.info('Removing room %d', room_id)
|
||||
room = self._rooms[room_id]
|
||||
for client in room.clients:
|
||||
client.close()
|
||||
room.stop_and_close()
|
||||
del self._rooms[room_id]
|
||||
|
||||
|
||||
# noinspection PyAbstractClass
|
||||
class ChatHandler(tornado.websocket.WebSocketHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._close_on_timeout_future = None
|
||||
self.room_id = None
|
||||
self.auto_translate = False
|
||||
|
||||
def open(self):
|
||||
logger.info('Websocket connected %s', self.request.remote_ip)
|
||||
self._close_on_timeout_future = asyncio.ensure_future(self._close_on_timeout())
|
||||
|
||||
async def _close_on_timeout(self):
|
||||
try:
|
||||
# 超过一定时间还没加入房间则断开
|
||||
await asyncio.sleep(10)
|
||||
logger.warning('Client %s joining room timed out', self.request.remote_ip)
|
||||
self.close()
|
||||
except (asyncio.CancelledError, tornado.websocket.WebSocketClosedError):
|
||||
pass
|
||||
|
||||
def on_message(self, message):
|
||||
try:
|
||||
body = json.loads(message)
|
||||
cmd = body['cmd']
|
||||
if cmd == Command.HEARTBEAT:
|
||||
return
|
||||
elif cmd == Command.JOIN_ROOM:
|
||||
if self.has_joined_room:
|
||||
return
|
||||
self.room_id = int(body['data']['roomId'])
|
||||
logger.info('Client %s is joining room %d', self.request.remote_ip, self.room_id)
|
||||
try:
|
||||
cfg = body['data']['config']
|
||||
self.auto_translate = cfg['autoTranslate']
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
asyncio.ensure_future(room_manager.add_client(self.room_id, self))
|
||||
self._close_on_timeout_future.cancel()
|
||||
self._close_on_timeout_future = None
|
||||
else:
|
||||
logger.warning('Unknown cmd, client: %s, cmd: %d, body: %s', self.request.remote_ip, cmd, body)
|
||||
except:
|
||||
logger.exception('on_message error, client: %s, message: %s', self.request.remote_ip, message)
|
||||
|
||||
def on_close(self):
|
||||
logger.info('Websocket disconnected %s room: %s', self.request.remote_ip, str(self.room_id))
|
||||
if self.has_joined_room:
|
||||
room_manager.del_client(self.room_id, self)
|
||||
if self._close_on_timeout_future is not None:
|
||||
self._close_on_timeout_future.cancel()
|
||||
self._close_on_timeout_future = None
|
||||
|
||||
# 跨域测试用
|
||||
def check_origin(self, origin):
|
||||
if self.application.settings['debug']:
|
||||
return True
|
||||
return super().check_origin(origin)
|
||||
|
||||
# 测试用
|
||||
async def send_test_message(self):
|
||||
base_data = {
|
||||
'avatarUrl': await models.avatar.get_avatar_url(300474),
|
||||
'timestamp': time.time(),
|
||||
'authorName': 'xfgryujk',
|
||||
}
|
||||
text_data = [
|
||||
# 0: avatarUrl
|
||||
base_data['avatarUrl'],
|
||||
# 1: timestamp
|
||||
base_data['timestamp'],
|
||||
# 2: authorName
|
||||
base_data['authorName'],
|
||||
# 3: authorType
|
||||
0,
|
||||
# 4: content
|
||||
'我能吞下玻璃而不伤身体',
|
||||
# 5: privilegeType
|
||||
0,
|
||||
# 6: isGiftDanmaku
|
||||
0,
|
||||
# 7: authorLevel
|
||||
20,
|
||||
# 8: isNewbie
|
||||
0,
|
||||
# 9: isMobileVerified
|
||||
1,
|
||||
# 10: medalLevel
|
||||
0,
|
||||
# 11: id
|
||||
uuid.uuid4().hex,
|
||||
# 12: translation
|
||||
''
|
||||
]
|
||||
member_data = {
|
||||
**base_data,
|
||||
'id': uuid.uuid4().hex
|
||||
}
|
||||
gift_data = {
|
||||
**base_data,
|
||||
'id': uuid.uuid4().hex,
|
||||
'totalCoin': 450000,
|
||||
'giftName': '摩天大楼',
|
||||
'num': 1
|
||||
}
|
||||
sc_data = {
|
||||
**base_data,
|
||||
'id': str(random.randint(1, 65535)),
|
||||
'price': 30,
|
||||
'content': 'The quick brown fox jumps over the lazy dog',
|
||||
'translation': ''
|
||||
}
|
||||
self.send_message(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)
|
||||
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_message(Command.DEL_SUPER_CHAT, {'ids': [sc_data['id']]})
|
||||
self.send_message(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)
|
||||
|
||||
@property
|
||||
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})
|
||||
try:
|
||||
self.write_message(body)
|
||||
except tornado.websocket.WebSocketClosedError:
|
||||
pass
|
||||
25
api/main.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import tornado.web
|
||||
|
||||
import api.base
|
||||
import config
|
||||
import update
|
||||
|
||||
|
||||
class MainHandler(tornado.web.StaticFileHandler):
|
||||
"""为了使用Vue Router的history模式,把所有请求转发到index.html"""
|
||||
async def get(self, *args, **kwargs):
|
||||
await super().get('index.html', *args, **kwargs)
|
||||
|
||||
|
||||
# noinspection PyAbstractClass
|
||||
class ServerInfoHandler(api.base.ApiHandler):
|
||||
async def get(self):
|
||||
cfg = config.get_config()
|
||||
self.write({
|
||||
'version': update.VERSION,
|
||||
'config': {
|
||||
'enableTranslate': cfg.enable_translate
|
||||
}
|
||||
})
|
||||
2
blivedm
45
config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import configparser
|
||||
import logging
|
||||
import os
|
||||
from typing import *
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_PATH = os.path.join('data', 'config.ini')
|
||||
|
||||
_config: Optional['AppConfig'] = None
|
||||
|
||||
|
||||
def init():
|
||||
reload()
|
||||
|
||||
|
||||
def reload():
|
||||
config = AppConfig()
|
||||
if config.load(CONFIG_PATH):
|
||||
global _config
|
||||
_config = config
|
||||
|
||||
|
||||
def get_config():
|
||||
return _config
|
||||
|
||||
|
||||
class AppConfig:
|
||||
def __init__(self):
|
||||
self.database_url = 'sqlite:///data/database.db'
|
||||
self.enable_translate = True
|
||||
|
||||
def load(self, path):
|
||||
config = configparser.ConfigParser()
|
||||
config.read(path)
|
||||
try:
|
||||
app_section = config['app']
|
||||
self.database_url = app_section['database_url']
|
||||
self.enable_translate = app_section.getboolean('enable_translate')
|
||||
except (KeyError, ValueError):
|
||||
logger.exception('Failed to load config:')
|
||||
return False
|
||||
return True
|
||||
11
data/config.ini
Normal file
@@ -0,0 +1,11 @@
|
||||
[app]
|
||||
# See https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls
|
||||
database_url = sqlite:///data/database.db
|
||||
# Enable auto translate to Japanese
|
||||
enable_translate = true
|
||||
|
||||
|
||||
# DON'T modify this section
|
||||
[DEFAULT]
|
||||
database_url = sqlite:///data/database.db
|
||||
enable_translate = true
|
||||
@@ -1,5 +1,13 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/app'
|
||||
],
|
||||
plugins: [
|
||||
[
|
||||
'component', {
|
||||
'libraryName': 'element-ui',
|
||||
'styleLibraryName': 'theme-chalk'
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
15938
frontend/package-lock.json
generated
@@ -20,8 +20,9 @@
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "^3.7.0",
|
||||
"@vue/cli-plugin-eslint": "^3.7.0",
|
||||
"@vue/cli-service": "^3.7.0",
|
||||
"@vue/cli-service": "^4.2.2",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"babel-plugin-component": "^1.1.1",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-plugin-vue": "^5.0.0",
|
||||
"vue-template-compiler": "^2.5.21"
|
||||
|
||||
BIN
frontend/public/static/img/tutorial/tutorial-1.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
frontend/public/static/img/tutorial/tutorial-2.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
frontend/public/static/img/tutorial/tutorial-3.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
frontend/public/static/img/tutorial/tutorial-4.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/public/static/img/tutorial/tutorial-5.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
@@ -1,13 +1,12 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import {mergeConfig} from '@/utils'
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
minGiftPrice: 7, // $1
|
||||
mergeSimilarDanmaku: true,
|
||||
showDanmaku: true,
|
||||
showGift: true,
|
||||
maxSpeed: 4,
|
||||
showGiftName: false,
|
||||
mergeSimilarDanmaku: true,
|
||||
mergeGift: true,
|
||||
maxNumber: 60,
|
||||
|
||||
blockGiftDanmaku: true,
|
||||
@@ -18,7 +17,7 @@ export const DEFAULT_CONFIG = {
|
||||
blockUsers: '',
|
||||
blockMedalLevel: 0,
|
||||
|
||||
css: ''
|
||||
autoTranslate: false
|
||||
}
|
||||
|
||||
export function setLocalConfig (config) {
|
||||
@@ -32,27 +31,3 @@ export function getLocalConfig () {
|
||||
}
|
||||
return mergeConfig(JSON.parse(window.localStorage.config), DEFAULT_CONFIG)
|
||||
}
|
||||
|
||||
export async function createRemoteConfig (config) {
|
||||
config = mergeConfig(config, DEFAULT_CONFIG)
|
||||
return (await axios.post('/config', config)).data
|
||||
}
|
||||
|
||||
export async function setRemoteConfig (id, config) {
|
||||
config = mergeConfig(config, DEFAULT_CONFIG)
|
||||
return (await axios.put(`/config/${id}`, config)).data
|
||||
}
|
||||
|
||||
export async function getRemoteConfig (id) {
|
||||
let config = (await axios.get(`/config/${id}`)).data
|
||||
return mergeConfig(config, DEFAULT_CONFIG)
|
||||
}
|
||||
|
||||
export default {
|
||||
DEFAULT_CONFIG,
|
||||
setLocalConfig,
|
||||
getLocalConfig,
|
||||
createRemoteConfig,
|
||||
setRemoteConfig,
|
||||
getRemoteConfig
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<script>
|
||||
import ImgShadow from './ImgShadow.vue'
|
||||
import utils from '@/utils'
|
||||
import * as utils from '@/utils'
|
||||
|
||||
export default {
|
||||
name: 'LegacyPaidMessage',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<yt-live-chat-paid-message-renderer class="style-scope yt-live-chat-item-list-renderer" allow-animations :style="{
|
||||
<yt-live-chat-paid-message-renderer class="style-scope yt-live-chat-item-list-renderer" allow-animations
|
||||
:show-only-header="!content" :style="{
|
||||
'--yt-live-chat-paid-message-primary-color': color.contentBg,
|
||||
'--yt-live-chat-paid-message-secondary-color': color.headerBg,
|
||||
'--yt-live-chat-paid-message-header-color': color.header,
|
||||
@@ -21,7 +22,7 @@
|
||||
<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" v-show="content">
|
||||
<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>
|
||||
@@ -33,7 +34,7 @@
|
||||
<script>
|
||||
import ImgShadow from './ImgShadow.vue'
|
||||
import * as constants from './constants'
|
||||
import utils from '@/utils'
|
||||
import * as utils from '@/utils'
|
||||
|
||||
export default {
|
||||
name: 'PaidMessage',
|
||||
|
||||
@@ -17,10 +17,12 @@
|
||||
></author-badge>
|
||||
</span>
|
||||
</yt-live-chat-author-chip>
|
||||
<span id="message" class="style-scope yt-live-chat-text-message-renderer">{{content}}</span>
|
||||
<el-badge :value="repeated" :max="99" v-show="repeated > 1" class="style-scope yt-live-chat-text-message-renderer"
|
||||
:style="{'--repeated-mark-color': repeatedMarkColor}"
|
||||
></el-badge>
|
||||
<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}"
|
||||
></el-badge>
|
||||
</span>
|
||||
</div>
|
||||
</yt-live-chat-text-message-renderer>
|
||||
</template>
|
||||
@@ -29,7 +31,7 @@
|
||||
import ImgShadow from './ImgShadow.vue'
|
||||
import AuthorBadge from './AuthorBadge.vue'
|
||||
import * as constants from './constants'
|
||||
import utils from '@/utils'
|
||||
import * as utils from '@/utils'
|
||||
|
||||
// HSL
|
||||
const REPEATED_MARK_COLOR_START = [210, 100.0, 62.5]
|
||||
@@ -77,11 +79,11 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
yt-live-chat-text-message-renderer>#content>.el-badge {
|
||||
margin-left: 10px;
|
||||
yt-live-chat-text-message-renderer>#content>#message>.el-badge {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
yt-live-chat-text-message-renderer>#content>.el-badge .el-badge__content {
|
||||
yt-live-chat-text-message-renderer>#content>#message>.el-badge .el-badge__content {
|
||||
font-size: 12px !important;
|
||||
line-height: 18px !important;
|
||||
text-shadow: none !important;
|
||||
|
||||
@@ -34,18 +34,19 @@
|
||||
<paid-message :key="pinnedMessage.id" v-else
|
||||
class="style-scope yt-live-chat-ticker-renderer"
|
||||
:price="pinnedMessage.price" :avatarUrl="pinnedMessage.avatarUrl" :authorName="pinnedMessage.authorName"
|
||||
:time="pinnedMessage.time" :content="pinnedMessage.content"
|
||||
:time="pinnedMessage.time" :content="showContent"
|
||||
></paid-message>
|
||||
</template>
|
||||
</yt-live-chat-ticker-renderer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as config from '@/api/config'
|
||||
import {formatCurrency} from '@/utils'
|
||||
import ImgShadow from './ImgShadow.vue'
|
||||
import LegacyPaidMessage from './LegacyPaidMessage.vue'
|
||||
import PaidMessage from './PaidMessage.vue'
|
||||
import * as constants from './constants'
|
||||
import {formatCurrency} from '@/utils'
|
||||
|
||||
export default {
|
||||
name: 'Ticker',
|
||||
@@ -55,7 +56,11 @@ export default {
|
||||
PaidMessage
|
||||
},
|
||||
props: {
|
||||
messages: Array
|
||||
messages: Array,
|
||||
showGiftName: {
|
||||
type: Boolean,
|
||||
default: config.DEFAULT_CONFIG.showGiftName
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -65,6 +70,18 @@ export default {
|
||||
pinnedMessage: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showContent() {
|
||||
if (!this.pinnedMessage) {
|
||||
return ''
|
||||
}
|
||||
if (this.pinnedMessage.type === constants.MESSAGE_TYPE_GIFT) {
|
||||
return constants.getGiftShowContent(this.pinnedMessage, this.showGiftName)
|
||||
} else {
|
||||
return constants.getShowContent(this.pinnedMessage)
|
||||
}
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.clearInterval(this.updateTimerId)
|
||||
},
|
||||
|
||||
@@ -18,8 +18,11 @@ export const GUARD_LEVEL_TO_TEXT = [
|
||||
]
|
||||
|
||||
export const MESSAGE_TYPE_TEXT = 0
|
||||
export const MESSAGE_TYPE_MEMBER = 1
|
||||
export const MESSAGE_TYPE_SUPER_CHAT = 2
|
||||
export const MESSAGE_TYPE_GIFT = 1
|
||||
export const MESSAGE_TYPE_MEMBER = 2
|
||||
export const MESSAGE_TYPE_SUPER_CHAT = 3
|
||||
export const MESSAGE_TYPE_DEL = 4
|
||||
export const MESSAGE_TYPE_UPDATE = 5
|
||||
|
||||
// 美元 -> 人民币 汇率
|
||||
const EXCHANGE_RATE = 7
|
||||
@@ -118,3 +121,17 @@ export function getPriceConfig (price) {
|
||||
}
|
||||
return PRICE_CONFIGS[PRICE_CONFIGS.length - 1]
|
||||
}
|
||||
|
||||
export function getShowContent(message) {
|
||||
if (message.translation) {
|
||||
return `${message.content}(${message.translation})`
|
||||
}
|
||||
return message.content
|
||||
}
|
||||
|
||||
export function getGiftShowContent(message, showGiftName) {
|
||||
if (!showGiftName) {
|
||||
return ''
|
||||
}
|
||||
return `Sent ${message.giftName}x${message.num}`
|
||||
}
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
<template>
|
||||
<yt-live-chat-renderer class="style-scope yt-live-chat-app" style="--scrollbar-width:11px;" hide-timestamps>
|
||||
<ticker class="style-scope yt-live-chat-renderer" :messages="paidMessages" :hidden="paidMessages.length === 0"></ticker>
|
||||
<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"
|
||||
:hidden="paidMessages.length === 0"
|
||||
></ticker>
|
||||
<yt-live-chat-item-list-renderer class="style-scope yt-live-chat-renderer" allow-scroll>
|
||||
<div id="item-scroller" ref="scroller" class="style-scope yt-live-chat-item-list-renderer animated" @scroll="onScroll">
|
||||
<div id="item-offset" class="style-scope yt-live-chat-item-list-renderer" style="height: 1800px;">
|
||||
<div id="items" class="style-scope yt-live-chat-item-list-renderer" style="overflow: hidden; transform: translateY(0px);">
|
||||
<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)`}"
|
||||
>
|
||||
<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="message.content" :privilegeType="message.privilegeType"
|
||||
:authorType="message.authorType" :content="getShowContent(message)" :privilegeType="message.privilegeType"
|
||||
: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="message.authorName"
|
||||
:time="message.time" :content="getGiftShowContent(message)"
|
||||
></paid-message>
|
||||
<legacy-paid-message :key="message.id" v-else-if="message.type === MESSAGE_TYPE_MEMBER"
|
||||
class="style-scope yt-live-chat-item-list-renderer"
|
||||
:avatarUrl="message.avatarUrl" :title="message.title" :content="message.content"
|
||||
:time="message.time"
|
||||
></legacy-paid-message>
|
||||
<paid-message :key="message.id" v-else
|
||||
<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="message.authorName"
|
||||
:time="message.time" :content="message.content"
|
||||
:time="message.time" :content="getShowContent(message)"
|
||||
></paid-message>
|
||||
</template>
|
||||
</div>
|
||||
@@ -31,12 +42,16 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as config from '@/api/config'
|
||||
import Ticker from './Ticker.vue'
|
||||
import TextMessage from './TextMessage.vue'
|
||||
import LegacyPaidMessage from './LegacyPaidMessage.vue'
|
||||
import PaidMessage from './PaidMessage.vue'
|
||||
import * as constants from './constants'
|
||||
|
||||
const CHAT_SMOOTH_ANIMATION_TIME_MS = 84
|
||||
const SCROLLED_TO_BOTTOM_EPSILON = 15
|
||||
|
||||
export default {
|
||||
name: 'ChatRenderer',
|
||||
components: {
|
||||
@@ -46,19 +61,60 @@ export default {
|
||||
PaidMessage
|
||||
},
|
||||
props: {
|
||||
paidMessages: Array,
|
||||
messages: Array,
|
||||
css: String
|
||||
css: String,
|
||||
maxNumber: {
|
||||
type: Number,
|
||||
default: config.DEFAULT_CONFIG.maxNumber
|
||||
},
|
||||
showGiftName: {
|
||||
type: Boolean,
|
||||
default: config.DEFAULT_CONFIG.showGiftName
|
||||
}
|
||||
},
|
||||
data() {
|
||||
let styleElement = document.createElement('style')
|
||||
document.head.appendChild(styleElement)
|
||||
return {
|
||||
MESSAGE_TYPE_TEXT: constants.MESSAGE_TYPE_TEXT,
|
||||
MESSAGE_TYPE_GIFT: constants.MESSAGE_TYPE_GIFT,
|
||||
MESSAGE_TYPE_MEMBER: constants.MESSAGE_TYPE_MEMBER,
|
||||
MESSAGE_TYPE_SUPER_CHAT: constants.MESSAGE_TYPE_SUPER_CHAT,
|
||||
|
||||
styleElement,
|
||||
canAutoScroll: true
|
||||
messages: [], // 显示的消息
|
||||
paidMessages: [], // 固定在上方的消息
|
||||
|
||||
smoothedMessageQueue: [], // 平滑消息队列,由外部调用addMessages等方法添加
|
||||
emitSmoothedMessageTimerId: null, // 消费平滑消息队列的定时器ID
|
||||
enqueueIntervals: [], // 最近进队列的时间间隔,用来估计下次进队列的时间
|
||||
lastEnqueueTime: null, // 上次进队列的时间
|
||||
estimatedEnqueueInterval: null, // 估计的下次进队列时间间隔
|
||||
|
||||
messagesBuffer: [], // 暂时未显示的消息,当不能自动滚动时会积压在这
|
||||
preinsertHeight: 0, // 插入新消息之前items的高度
|
||||
isSmoothed: true, // 是否平滑滚动,当消息太快时不平滑滚动
|
||||
chatRateMs: 1000, // 用来计算消息速度
|
||||
scrollPixelsRemaining: 0, // 平滑滚动剩余像素
|
||||
scrollTimeRemainingMs: 0, // 平滑滚动剩余时间
|
||||
lastSmoothChatMessageAddMs: null, // 上次showNewMessages时间
|
||||
smoothScrollRafHandle: null, // 平滑滚动requestAnimationFrame句柄
|
||||
lastSmoothScrollUpdate: null, // 平滑滚动上一帧时间
|
||||
|
||||
atBottom: true, // 滚动到底部,用来判断能否自动滚动
|
||||
cantScrollStartTime: null // 开始不能自动滚动的时间,用来防止卡住
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canScrollToBottom() {
|
||||
return this.atBottom/* || this.allowScroll*/
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
css(val) {
|
||||
this.styleElement.innerText = val
|
||||
},
|
||||
canScrollToBottom(val) {
|
||||
this.cantScrollStartTime = val ? null : new Date()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -67,24 +123,404 @@ export default {
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.head.removeChild(this.styleElement)
|
||||
},
|
||||
updated() {
|
||||
if (this.canAutoScroll) {
|
||||
this.scrollToBottom()
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
css(val) {
|
||||
this.styleElement.innerText = val
|
||||
if (this.emitSmoothedMessageTimerId) {
|
||||
window.clearTimeout(this.emitSmoothedMessageTimerId)
|
||||
this.emitSmoothedMessageTimerId = null
|
||||
}
|
||||
this.clearMessages()
|
||||
},
|
||||
methods: {
|
||||
getGiftShowContent(message) {
|
||||
return constants.getGiftShowContent(message, this.showGiftName)
|
||||
},
|
||||
getShowContent: constants.getShowContent,
|
||||
|
||||
addMessage(message) {
|
||||
this.addMessages([message])
|
||||
},
|
||||
addMessages(messages) {
|
||||
this.enqueueMessages(messages)
|
||||
},
|
||||
mergeSimilarText(content) {
|
||||
content = content.trim().toLowerCase()
|
||||
let res = false
|
||||
this.forEachRecentMessage(5, message => {
|
||||
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
|
||||
return true
|
||||
}
|
||||
let messageContent = message.content.trim().toLowerCase()
|
||||
let longer, shorter
|
||||
if (messageContent.length > content.length) {
|
||||
longer = messageContent
|
||||
shorter = content
|
||||
} else {
|
||||
longer = content
|
||||
shorter = messageContent
|
||||
}
|
||||
if (longer.indexOf(shorter) !== -1 // 长的包含短的
|
||||
&& longer.length - shorter.length < shorter.length // 长度差较小
|
||||
) {
|
||||
// 其实有小概率导致弹幕卡住
|
||||
message.repeated++
|
||||
res = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return res
|
||||
},
|
||||
mergeSimilarGift(authorName, price, giftName, num) {
|
||||
let res = false
|
||||
this.forEachRecentMessage(5, message => {
|
||||
if (message.type === constants.MESSAGE_TYPE_GIFT
|
||||
&& message.authorName === authorName
|
||||
&& message.giftName === giftName
|
||||
) {
|
||||
message.price += price
|
||||
message.num += num
|
||||
res = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return res
|
||||
},
|
||||
forEachRecentMessage(num, callback) {
|
||||
// 从新到老遍历num条消息
|
||||
for (let i = this.smoothedMessageQueue.length - 1; i >= 0 && num > 0; i--) {
|
||||
let messageGroup = this.smoothedMessageQueue[i]
|
||||
for (let j = messageGroup.length - 1; j >= 0 && num-- > 0; j--) {
|
||||
if (!callback(messageGroup[j])) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let arr of [this.messagesBuffer, this.messages]) {
|
||||
for (let i = arr.length - 1; i >= 0 && num-- > 0; i--) {
|
||||
if (!callback(arr[i])) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
delMessage(id) {
|
||||
this.delMessages([id])
|
||||
},
|
||||
delMessages(ids) {
|
||||
this.enqueueMessages(ids.map(id => {
|
||||
return {
|
||||
type: constants.MESSAGE_TYPE_DEL,
|
||||
id
|
||||
}
|
||||
}))
|
||||
},
|
||||
clearMessages() {
|
||||
this.messages = []
|
||||
this.paidMessages = []
|
||||
this.smoothedMessageQueue = []
|
||||
this.messagesBuffer = []
|
||||
this.isSmoothed = true
|
||||
this.lastSmoothChatMessageAddMs = null
|
||||
this.chatRateMs = 1000
|
||||
this.lastSmoothScrollUpdate = null
|
||||
this.scrollTimeRemainingMs = this.scrollPixelsRemaining = 0
|
||||
this.smoothScrollRafHandle = null
|
||||
this.preinsertHeight = 0
|
||||
this.maybeResizeScrollContainer()
|
||||
if (!this.atBottom) {
|
||||
this.scrollToBottom()
|
||||
}
|
||||
},
|
||||
updateMessage(id, newValuesObj) {
|
||||
this.enqueueMessages([{
|
||||
type: constants.MESSAGE_TYPE_UPDATE,
|
||||
id,
|
||||
newValuesObj
|
||||
}])
|
||||
},
|
||||
|
||||
enqueueMessages(messages) {
|
||||
if (this.lastEnqueueTime) {
|
||||
let interval = new Date() - this.lastEnqueueTime
|
||||
// 理论上B站发包间隔1S,如果不过滤间隔太短的会导致消息平滑失效
|
||||
if (interval > 100) {
|
||||
this.enqueueIntervals.push(interval)
|
||||
if (this.enqueueIntervals.length > 5) {
|
||||
this.enqueueIntervals.splice(0, this.enqueueIntervals.length - 5)
|
||||
}
|
||||
this.estimatedEnqueueInterval = Math.max(...this.enqueueIntervals)
|
||||
}
|
||||
}
|
||||
this.lastEnqueueTime = new Date()
|
||||
|
||||
// 只有要显示的消息需要平滑
|
||||
let messageGroup = []
|
||||
for (let message of messages) {
|
||||
messageGroup.push(message)
|
||||
if (message.type !== constants.MESSAGE_TYPE_DEL && message.type !== constants.MESSAGE_TYPE_UPDATE) {
|
||||
this.smoothedMessageQueue.push(messageGroup)
|
||||
messageGroup = []
|
||||
}
|
||||
}
|
||||
if (messageGroup.length > 0) {
|
||||
this.smoothedMessageQueue.push(messageGroup)
|
||||
}
|
||||
|
||||
if (!this.emitSmoothedMessageTimerId) {
|
||||
this.emitSmoothedMessageTimerId = window.setTimeout(this.emitSmoothedMessages)
|
||||
}
|
||||
},
|
||||
emitSmoothedMessages() {
|
||||
this.emitSmoothedMessageTimerId = null
|
||||
if (this.smoothedMessageQueue.length <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 估计的下次进队列剩余时间
|
||||
let estimatedNextEnqueueRemainTime = 10 * 1000
|
||||
if (this.estimatedEnqueueInterval) {
|
||||
estimatedNextEnqueueRemainTime = Math.max(this.lastEnqueueTime - new Date() + this.estimatedEnqueueInterval, 1)
|
||||
}
|
||||
// 最快80ms/条,计算发送的消息数,保证在下次进队列之前消费完队列
|
||||
let groupNumToEmit
|
||||
if (this.smoothedMessageQueue.length < estimatedNextEnqueueRemainTime / 80) {
|
||||
// 队列中消息数很少,每次发1条也能发完
|
||||
groupNumToEmit = 1
|
||||
} else {
|
||||
// 每次发1条以上,保证按最快速度能发完
|
||||
groupNumToEmit = Math.ceil(this.smoothedMessageQueue.length / (estimatedNextEnqueueRemainTime / 80))
|
||||
}
|
||||
|
||||
let messageGroups = this.smoothedMessageQueue.splice(0, groupNumToEmit)
|
||||
let mergedGroup = []
|
||||
for (let messageGroup of messageGroups) {
|
||||
for (let message of messageGroup) {
|
||||
mergedGroup.push(message)
|
||||
}
|
||||
}
|
||||
this.handleMessageGroup(mergedGroup)
|
||||
|
||||
if (this.smoothedMessageQueue.length <= 0) {
|
||||
return
|
||||
}
|
||||
let sleepTime
|
||||
if (groupNumToEmit == 1) {
|
||||
// 队列中消息数很少,随便定个80-1000ms的时间
|
||||
sleepTime = estimatedNextEnqueueRemainTime / this.smoothedMessageQueue.length
|
||||
sleepTime *= 0.5 + Math.random()
|
||||
if (sleepTime > 1000) {
|
||||
sleepTime = 1000
|
||||
} else if (sleepTime < 80) {
|
||||
sleepTime = 80
|
||||
}
|
||||
} else {
|
||||
// 按最快速度发
|
||||
sleepTime = 80
|
||||
}
|
||||
this.emitSmoothedMessageTimerId = window.setTimeout(this.emitSmoothedMessages, sleepTime)
|
||||
},
|
||||
|
||||
handleMessageGroup(messageGroup) {
|
||||
if (messageGroup.length <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
this.maybeResizeScrollContainer(),
|
||||
this.flushMessagesBuffer()
|
||||
this.$nextTick(this.maybeScrollToBottom)
|
||||
},
|
||||
handleAddMessage(message) {
|
||||
message = {
|
||||
...message,
|
||||
addTime: new Date() // 添加一个本地时间给Ticker用,防止本地时间和服务器时间相差很大的情况
|
||||
}
|
||||
this.messagesBuffer.push(message)
|
||||
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
|
||||
this.paidMessages.unshift(message)
|
||||
}
|
||||
},
|
||||
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) {
|
||||
arr.splice(i, 1)
|
||||
this.resetSmoothScroll()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
handleUpdateMessage({id, newValuesObj}) {
|
||||
// 遍历滚动的消息
|
||||
this.forEachRecentMessage(999999999, message => {
|
||||
if (message.id !== id) {
|
||||
return true
|
||||
}
|
||||
for (let name in newValuesObj) {
|
||||
message[name] = newValuesObj[name]
|
||||
}
|
||||
return false
|
||||
})
|
||||
// 遍历固定的消息
|
||||
for (let message of this.paidMessages) {
|
||||
if (message.id !== id) {
|
||||
continue
|
||||
}
|
||||
for (let name in newValuesObj) {
|
||||
message[name] = newValuesObj[name]
|
||||
}
|
||||
break
|
||||
}
|
||||
this.resetSmoothScroll()
|
||||
},
|
||||
|
||||
async flushMessagesBuffer() {
|
||||
if (this.messagesBuffer.length <= 0) {
|
||||
return
|
||||
}
|
||||
if (!this.canScrollToBottomOrTimedOut()) {
|
||||
if (this.messagesBuffer.length > this.maxNumber) {
|
||||
// 未显示消息数 > 最大可显示数,丢弃
|
||||
this.messagesBuffer.splice(0, this.messagesBuffer.length - this.maxNumber)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let removeNum = Math.max(this.messages.length + this.messagesBuffer.length - this.maxNumber, 0)
|
||||
if (removeNum > 0) {
|
||||
this.messages.splice(0, removeNum)
|
||||
// 防止同时添加和删除项目时所有的项目重新渲染 https://github.com/vuejs/vue/issues/6857
|
||||
await this.$nextTick()
|
||||
}
|
||||
|
||||
this.preinsertHeight = this.$refs.items.clientHeight
|
||||
for (let message of this.messagesBuffer) {
|
||||
this.messages.push(message)
|
||||
}
|
||||
this.messagesBuffer = []
|
||||
// 等items高度变化
|
||||
this.$nextTick(this.showNewMessages)
|
||||
},
|
||||
showNewMessages() {
|
||||
let hasScrollBar = this.$refs.items.clientHeight > this.$refs.scroller.clientHeight
|
||||
this.$refs.itemOffset.style.height = `${this.$refs.items.clientHeight}px`
|
||||
if (!this.canScrollToBottomOrTimedOut() || !hasScrollBar) {
|
||||
return
|
||||
}
|
||||
|
||||
// 计算剩余像素
|
||||
this.scrollPixelsRemaining += this.$refs.items.clientHeight - this.preinsertHeight
|
||||
this.scrollToBottom()
|
||||
|
||||
// 计算是否平滑滚动、剩余时间
|
||||
if (!this.lastSmoothChatMessageAddMs) {
|
||||
this.lastSmoothChatMessageAddMs = performance.now()
|
||||
}
|
||||
let interval = performance.now() - this.lastSmoothChatMessageAddMs
|
||||
this.chatRateMs = 0.9 * this.chatRateMs + 0.1 * interval
|
||||
if (this.isSmoothed) {
|
||||
if (this.chatRateMs < 400) {
|
||||
this.isSmoothed = false
|
||||
}
|
||||
} else {
|
||||
if (this.chatRateMs > 450) {
|
||||
this.isSmoothed = true
|
||||
}
|
||||
}
|
||||
this.scrollTimeRemainingMs += this.isSmoothed ? CHAT_SMOOTH_ANIMATION_TIME_MS : 0
|
||||
|
||||
if (!this.smoothScrollRafHandle) {
|
||||
this.smoothScrollRafHandle = window.requestAnimationFrame(this.smoothScroll)
|
||||
}
|
||||
this.lastSmoothChatMessageAddMs = performance.now()
|
||||
},
|
||||
smoothScroll(time) {
|
||||
if (!this.lastSmoothScrollUpdate) {
|
||||
// 第一帧
|
||||
this.lastSmoothScrollUpdate = time
|
||||
this.smoothScrollRafHandle = window.requestAnimationFrame(this.smoothScroll)
|
||||
return
|
||||
}
|
||||
|
||||
let interval = time - this.lastSmoothScrollUpdate
|
||||
if (
|
||||
this.scrollPixelsRemaining <= 0 || this.scrollPixelsRemaining >= 400 // 已经滚动到底部或者离底部太远则结束
|
||||
|| interval >= 1000 // 离上一帧时间太久,可能用户切换到其他网页
|
||||
|| this.scrollTimeRemainingMs <= 0 // 时间已结束
|
||||
) {
|
||||
this.resetSmoothScroll()
|
||||
return
|
||||
}
|
||||
|
||||
let pixelsToScroll = interval / this.scrollTimeRemainingMs * this.scrollPixelsRemaining
|
||||
this.scrollPixelsRemaining -= pixelsToScroll
|
||||
if (this.scrollPixelsRemaining < 0) {
|
||||
this.scrollPixelsRemaining = 0
|
||||
}
|
||||
this.scrollTimeRemainingMs -= interval
|
||||
if (this.scrollTimeRemainingMs < 0) {
|
||||
this.scrollTimeRemainingMs = 0
|
||||
}
|
||||
this.lastSmoothScrollUpdate = time
|
||||
this.smoothScrollRafHandle = window.requestAnimationFrame(this.smoothScroll)
|
||||
},
|
||||
resetSmoothScroll() {
|
||||
this.scrollTimeRemainingMs = this.scrollPixelsRemaining = 0
|
||||
this.lastSmoothScrollUpdate = null
|
||||
if (this.smoothScrollRafHandle) {
|
||||
window.cancelAnimationFrame(this.smoothScrollRafHandle)
|
||||
this.smoothScrollRafHandle = null
|
||||
}
|
||||
},
|
||||
|
||||
maybeResizeScrollContainer() {
|
||||
this.$refs.itemOffset.style.height = `${this.$refs.items.clientHeight}px`
|
||||
this.maybeScrollToBottom()
|
||||
},
|
||||
maybeScrollToBottom() {
|
||||
if (this.canScrollToBottomOrTimedOut()) {
|
||||
this.scrollToBottom()
|
||||
}
|
||||
},
|
||||
scrollToBottom() {
|
||||
this.$refs.scroller.scrollTop = this.$refs.scroller.scrollHeight
|
||||
this.$refs.scroller.scrollTop = Math.pow(2, 24)
|
||||
this.atBottom = true
|
||||
},
|
||||
onScroll() {
|
||||
this.canAutoScroll = Math.abs(this.$refs.scroller.scrollHeight - this.$refs.scroller.scrollTop
|
||||
- this.$refs.scroller.clientHeight) < 2
|
||||
this.refreshCantScrollStartTime()
|
||||
let scroller = this.$refs.scroller
|
||||
this.atBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < SCROLLED_TO_BOTTOM_EPSILON
|
||||
this.flushMessagesBuffer()
|
||||
},
|
||||
canScrollToBottomOrTimedOut() {
|
||||
if (this.canScrollToBottom) {
|
||||
return true
|
||||
}
|
||||
// 防止在OBS中卡住,超过一定时间也可以自动滚动
|
||||
return new Date() - this.cantScrollStartTime >= 10 * 1000
|
||||
},
|
||||
refreshCantScrollStartTime() {
|
||||
// 有鼠标事件时刷新,防止用户看弹幕时自动滚动
|
||||
if (this.cantScrollStartTime) {
|
||||
this.cantScrollStartTime = new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ export default {
|
||||
sidebar: {
|
||||
home: 'Home',
|
||||
stylegen: 'Style generator',
|
||||
projectAddress: 'Project address'
|
||||
help: 'Help',
|
||||
projectAddress: 'Project address',
|
||||
giftRecord: 'Super Chat record'
|
||||
},
|
||||
home: {
|
||||
roomIdEmpty: "Room ID can't be empty",
|
||||
@@ -12,9 +14,10 @@ export default {
|
||||
roomId: 'Room ID',
|
||||
showDanmaku: 'Show messages',
|
||||
showGift: 'Show Super Chats',
|
||||
showGiftName: 'Show gift name',
|
||||
mergeSimilarDanmaku: 'Merge similar messages',
|
||||
mergeGift: 'Merge gifts',
|
||||
minGiftPrice: 'Min price of Super Chats to show (CNY)',
|
||||
maxSpeed: 'Max speed of messages (/second, 0 for unlimited)',
|
||||
maxNumber: 'Max number of messages',
|
||||
|
||||
block: 'Block',
|
||||
@@ -27,17 +30,15 @@ export default {
|
||||
blockUsers: 'Block users',
|
||||
blockMedalLevel: 'Block medal level lower than',
|
||||
|
||||
style: 'Style',
|
||||
advanced: 'Advanced',
|
||||
autoTranslate: 'Auto translate messages to Japanese',
|
||||
|
||||
roomUrl: 'Room URL',
|
||||
copy: 'Copy',
|
||||
saveConfig: 'Save config',
|
||||
enterRoom: 'Enter room',
|
||||
exportConfig: 'Export config',
|
||||
importConfig: 'Import config',
|
||||
|
||||
failedToSave: 'Failed to save: ',
|
||||
successfullySaved: 'Successfully saved',
|
||||
failedToParseConfig: 'Failed to parse config: '
|
||||
},
|
||||
stylegen: {
|
||||
@@ -106,5 +107,13 @@ export default {
|
||||
result: 'Result',
|
||||
copy: 'Copy',
|
||||
resetConfig: 'Reset config'
|
||||
},
|
||||
help: {
|
||||
help: 'Help',
|
||||
p1: '1. Copy the room ID from the Bilibili live room webpage',
|
||||
p2: '2. Enter the room ID into the room ID on the home page. Copy the room URL',
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export default {
|
||||
export default {
|
||||
sidebar: {
|
||||
home: 'トップページ',
|
||||
stylegen: 'スタイルジェネレータ',
|
||||
projectAddress: 'プロジェクトアドレス'
|
||||
help: 'ヘルプ',
|
||||
projectAddress: 'プロジェクトアドレス',
|
||||
giftRecord: 'スーパーチャット記録'
|
||||
},
|
||||
home: {
|
||||
roomIdEmpty: 'ルームのIDを空白にすることはできません',
|
||||
@@ -12,9 +14,10 @@
|
||||
roomId: 'ルームID',
|
||||
showDanmaku: 'コメントを表示する',
|
||||
showGift: 'スーパーチャットと新メンバーを表示する',
|
||||
showGiftName: 'ギフト名を表示する',
|
||||
mergeSimilarDanmaku: '同じコメントを合併する',
|
||||
mergeGift: 'ギフトを合併する',
|
||||
minGiftPrice: '最低表示スーパーチャット価格(CNY)',
|
||||
maxSpeed: 'コメントの最大速度(バー/秒、0は無制限)',
|
||||
maxNumber: '最大コメント数',
|
||||
|
||||
block: 'ブロック',
|
||||
@@ -27,17 +30,15 @@
|
||||
blockUsers: 'ブロックユーザー',
|
||||
blockMedalLevel: 'ブロック勲章等級がx未満',
|
||||
|
||||
style: 'スタイル',
|
||||
advanced: 'アドバンスド',
|
||||
autoTranslate: '自動翻訳コメントから日本語へ',
|
||||
|
||||
roomUrl: 'ルームのURL',
|
||||
copy: 'コピー',
|
||||
saveConfig: 'コンフィグを保存する',
|
||||
enterRoom: 'ルームに入る',
|
||||
exportConfig: 'コンフィグの導出',
|
||||
importConfig: 'コンフィグの導入',
|
||||
|
||||
failedToSave: '保存に失敗しました:',
|
||||
successfullySaved: '保存に成功しました',
|
||||
failedToParseConfig: 'コンフィグ解析に失敗しました'
|
||||
},
|
||||
stylegen: {
|
||||
@@ -106,5 +107,13 @@
|
||||
result: '結果',
|
||||
copy: 'コピー',
|
||||
resetConfig: 'デフォルトに戻す'
|
||||
},
|
||||
help: {
|
||||
help: 'ヘルプ',
|
||||
p1: '1. ビリビリの生放送ウェブから生放送ルームIDをこぴーする',
|
||||
p2: '2. ホームページでコピーしたIDを入力し、ルームのURLをこぴーする',
|
||||
p3: '3. スタイルジェネレータでお好みのコメント様子を選び、出力したCSSをコピーする',
|
||||
p4: '4. OBSでブラウザを新規作成する',
|
||||
p5: '5. プロパティでこぴーしたURLを入力し、カスタムCSSでスタイルジェネレータのCSSを入力する'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ export default {
|
||||
sidebar: {
|
||||
home: '首页',
|
||||
stylegen: '样式生成器',
|
||||
projectAddress: '项目地址'
|
||||
help: '帮助',
|
||||
projectAddress: '项目地址',
|
||||
giftRecord: '打赏记录'
|
||||
},
|
||||
home: {
|
||||
roomIdEmpty: '房间ID不能为空',
|
||||
@@ -11,10 +13,11 @@ export default {
|
||||
general: '常规',
|
||||
roomId: '房间ID',
|
||||
showDanmaku: '显示弹幕',
|
||||
showGift: '显示礼物和新舰长',
|
||||
showGift: '显示打赏和新舰长',
|
||||
showGiftName: '显示礼物名',
|
||||
mergeSimilarDanmaku: '合并相似弹幕',
|
||||
minGiftPrice: '最低显示礼物价格(元)',
|
||||
maxSpeed: '弹幕最大速度(条/秒,0为无限制)',
|
||||
mergeGift: '合并礼物',
|
||||
minGiftPrice: '最低显示打赏价格(元)',
|
||||
maxNumber: '最大弹幕数',
|
||||
|
||||
block: '屏蔽',
|
||||
@@ -27,17 +30,15 @@ export default {
|
||||
blockUsers: '屏蔽用户',
|
||||
blockMedalLevel: '屏蔽当前直播间勋章等级低于',
|
||||
|
||||
style: '样式',
|
||||
advanced: '高级',
|
||||
autoTranslate: '自动翻译弹幕到日语',
|
||||
|
||||
roomUrl: '房间URL',
|
||||
copy: '复制',
|
||||
saveConfig: '保存配置',
|
||||
enterRoom: '进入房间',
|
||||
exportConfig: '导出配置',
|
||||
importConfig: '导入配置',
|
||||
|
||||
failedToSave: '保存失败:',
|
||||
successfullySaved: '保存成功',
|
||||
failedToParseConfig: '配置解析失败:'
|
||||
},
|
||||
stylegen: {
|
||||
@@ -76,7 +77,7 @@ export default {
|
||||
moderatorMessageBgColor: '房管消息背景色',
|
||||
memberMessageBgColor: '舰长消息背景色',
|
||||
|
||||
scAndNewMember: '礼物、舰长',
|
||||
scAndNewMember: '打赏、舰长',
|
||||
firstLineFont: '第一行字体',
|
||||
firstLineFontSize: '第一行字体尺寸',
|
||||
firstLineLineHeight: '第一行行高(0为默认)',
|
||||
@@ -90,8 +91,8 @@ export default {
|
||||
scContentLineLineHeight: 'Super Chat内容行高(0为默认)',
|
||||
scContentLineColor: 'Super Chat内容颜色',
|
||||
showNewMemberBg: '显示新舰长背景',
|
||||
showScTicker: '显示Super Chat贴纸',
|
||||
showOtherThings: '显示Super Chat贴纸之外的内容',
|
||||
showScTicker: '显示Super Chat固定栏',
|
||||
showOtherThings: '显示Super Chat固定栏之外的内容',
|
||||
|
||||
animation: '动画',
|
||||
animateIn: '进入动画',
|
||||
@@ -106,5 +107,13 @@ export default {
|
||||
result: '结果',
|
||||
copy: '复制',
|
||||
resetConfig: '恢复默认设置'
|
||||
},
|
||||
help: {
|
||||
help: '帮助',
|
||||
p1: '1. 从B站直播间网页复制房间ID',
|
||||
p2: '2. 把房间ID输入到首页的房间ID,复制房间URL',
|
||||
p3: '3. 使用样式生成器生成样式,复制CSS',
|
||||
p4: '4. 在OBS中添加浏览器源',
|
||||
p5: '5. URL处输入之前复制的房间URL,自定义CSS处输入之前复制的CSS'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,19 @@
|
||||
<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>
|
||||
<a href="https://github.com/xfgryujk/blivechat" target="_blank">
|
||||
<el-menu-item>
|
||||
<i class="el-icon-share"></i>{{$t('sidebar.projectAddress')}}
|
||||
</el-menu-item>
|
||||
</a>
|
||||
<a href="https://bilisc.com/" target="_blank">
|
||||
<el-menu-item>
|
||||
<i class="el-icon-share"></i>{{$t('sidebar.giftRecord')}}
|
||||
</el-menu-item>
|
||||
</a>
|
||||
<el-submenu index="null">
|
||||
<template slot="title">
|
||||
<i class="el-icon-chat-line-square"></i>Language
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<h1 class="sidebar-title">blivechat</h1>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="version">
|
||||
v1.4.1
|
||||
</div>
|
||||
<sidebar></sidebar>
|
||||
</el-aside>
|
||||
<el-main>
|
||||
@@ -118,6 +121,17 @@ a, a:focus, a:hover {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sidebar-container .version {
|
||||
height: 30px;
|
||||
background: #2b2f3a;
|
||||
color: #aaa;
|
||||
font-weight: 600;
|
||||
line-height: 30px;
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-container .is-horizontal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import Vue from 'vue'
|
||||
import VueRouter from 'vue-router'
|
||||
import ElementUI from 'element-ui'
|
||||
import 'element-ui/lib/theme-chalk/index.css'
|
||||
import VueI18n from 'vue-i18n'
|
||||
import {
|
||||
Aside, Autocomplete, Badge, Button, Col, ColorPicker, Container, Divider, Form, FormItem, Image,
|
||||
Input, Main, Menu, MenuItem, Message, Row, Scrollbar, Slider, Submenu, Switch, TabPane, Tabs, Tooltip
|
||||
} from 'element-ui'
|
||||
import axios from 'axios'
|
||||
|
||||
import App from './App.vue'
|
||||
import Layout from './layout'
|
||||
import Home from './views/Home.vue'
|
||||
import StyleGenerator from './views/StyleGenerator'
|
||||
import Help from './views/Help'
|
||||
import Room from './views/Room.vue'
|
||||
import NotFound from './views/NotFound.vue'
|
||||
|
||||
@@ -22,8 +25,32 @@ if (process.env.NODE_ENV === 'development') {
|
||||
}
|
||||
|
||||
Vue.use(VueRouter)
|
||||
Vue.use(ElementUI)
|
||||
Vue.use(VueI18n)
|
||||
// 初始化element
|
||||
Vue.use(Aside)
|
||||
Vue.use(Autocomplete)
|
||||
Vue.use(Badge)
|
||||
Vue.use(Button)
|
||||
Vue.use(Col)
|
||||
Vue.use(ColorPicker)
|
||||
Vue.use(Container)
|
||||
Vue.use(Divider)
|
||||
Vue.use(Form)
|
||||
Vue.use(FormItem)
|
||||
Vue.use(Image)
|
||||
Vue.use(Input)
|
||||
Vue.use(Main)
|
||||
Vue.use(Menu)
|
||||
Vue.use(MenuItem)
|
||||
Vue.use(Row)
|
||||
Vue.use(Scrollbar)
|
||||
Vue.use(Slider)
|
||||
Vue.use(Submenu)
|
||||
Vue.use(Switch)
|
||||
Vue.use(TabPane)
|
||||
Vue.use(Tabs)
|
||||
Vue.use(Tooltip)
|
||||
Vue.prototype.$message = Message
|
||||
|
||||
Vue.config.ignoredElements = [
|
||||
/^yt-/
|
||||
@@ -37,7 +64,8 @@ const router = new VueRouter({
|
||||
component: Layout,
|
||||
children: [
|
||||
{path: '', component: Home},
|
||||
{path: 'stylegen', name: 'stylegen', component: StyleGenerator}
|
||||
{path: 'stylegen', name: 'stylegen', component: StyleGenerator},
|
||||
{path: 'help', name: 'help', component: Help}
|
||||
]
|
||||
},
|
||||
{path: '/room/:roomId', name: 'room', component: Room},
|
||||
|
||||
@@ -6,6 +6,21 @@ export function mergeConfig (config, defaultConfig) {
|
||||
return res
|
||||
}
|
||||
|
||||
export function toBool (val) {
|
||||
if (typeof val === 'string') {
|
||||
return ['false', 'no', 'off', '0', ''].indexOf(val.toLowerCase()) === -1
|
||||
}
|
||||
return !!val
|
||||
}
|
||||
|
||||
export function toInt (val, _default) {
|
||||
let res = parseInt(val)
|
||||
if (isNaN(res)) {
|
||||
res = _default
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
export function formatCurrency (price) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
minimumFractionDigits: price < 100 ? 2 : 0
|
||||
@@ -17,9 +32,3 @@ export function getTimeTextMinSec (date) {
|
||||
let sec = ('00' + date.getSeconds()).slice(-2)
|
||||
return `${min}:${sec}`
|
||||
}
|
||||
|
||||
export default {
|
||||
mergeConfig,
|
||||
formatCurrency,
|
||||
getTimeTextMinSec
|
||||
}
|
||||
|
||||
21
frontend/src/views/Help.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>{{$t('help.help')}}</h1>
|
||||
<p>{{$t('help.p1')}}</p>
|
||||
<p><el-image src="/static/img/tutorial/tutorial-1.png"></el-image></p>
|
||||
<p>{{$t('help.p2')}}</p>
|
||||
<p><el-image src="/static/img/tutorial/tutorial-2.png"></el-image></p>
|
||||
<p>{{$t('help.p3')}}</p>
|
||||
<p><el-image src="/static/img/tutorial/tutorial-3.png"></el-image></p>
|
||||
<p>{{$t('help.p4')}}</p>
|
||||
<p><el-image src="/static/img/tutorial/tutorial-4.png"></el-image></p>
|
||||
<p>{{$t('help.p5')}}</p>
|
||||
<p><el-image src="/static/img/tutorial/tutorial-5.png"></el-image></p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Help'
|
||||
}
|
||||
</script>
|
||||
@@ -16,15 +16,18 @@
|
||||
<el-form-item :label="$t('home.showGift')">
|
||||
<el-switch v-model="form.showGift"></el-switch>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('home.showGiftName')">
|
||||
<el-switch v-model="form.showGiftName"></el-switch>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('home.mergeSimilarDanmaku')">
|
||||
<el-switch v-model="form.mergeSimilarDanmaku"></el-switch>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('home.mergeGift')">
|
||||
<el-switch v-model="form.mergeGift"></el-switch>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('home.minGiftPrice')">
|
||||
<el-input v-model.number="form.minGiftPrice" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('home.maxSpeed')">
|
||||
<el-input v-model.number="form.maxSpeed" type="number" min="0"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('home.maxNumber')">
|
||||
<el-input v-model.number="form.maxNumber" type="number" min="1"></el-input>
|
||||
</el-form-item>
|
||||
@@ -54,20 +57,19 @@
|
||||
</el-form-item>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="$t('home.style')">
|
||||
<el-form-item label="CSS">
|
||||
<el-input v-model="form.css" type="textarea" :rows="20"></el-input>
|
||||
<el-tab-pane :label="$t('home.advanced')">
|
||||
<el-form-item :label="$t('home.autoTranslate')">
|
||||
<el-switch v-model="form.autoTranslate" :disabled="!serverConfig.enableTranslate"></el-switch>
|
||||
</el-form-item>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
|
||||
<el-divider></el-divider>
|
||||
<el-form-item :label="$t('home.roomUrl')" v-show="roomUrl">
|
||||
<el-input ref="roomUrlInput" readonly :value="roomUrl" style="width: calc(100% - 6em); margin-right: 1em;"></el-input>
|
||||
<el-form-item :label="$t('home.roomUrl')">
|
||||
<el-input ref="roomUrlInput" readonly :value="roomUrl" style="width: calc(100% - 8em); margin-right: 1em;"></el-input>
|
||||
<el-button type="primary" @click="copyUrl">{{$t('home.copy')}}</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveConfig">{{$t('home.saveConfig')}}</el-button>
|
||||
<el-button type="primary" :disabled="!roomUrl" @click="enterRoom">{{$t('home.enterRoom')}}</el-button>
|
||||
<el-button type="primary" @click="exportConfig">{{$t('home.exportConfig')}}</el-button>
|
||||
<el-button type="primary" @click="importConfig">{{$t('home.importConfig')}}</el-button>
|
||||
@@ -76,51 +78,53 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _ from 'lodash'
|
||||
import axios from 'axios'
|
||||
import download from 'downloadjs'
|
||||
|
||||
import {mergeConfig} from '@/utils'
|
||||
import config from '@/api/config'
|
||||
import * as config from '@/api/config'
|
||||
|
||||
export default {
|
||||
name: 'Home',
|
||||
data() {
|
||||
return {
|
||||
serverConfig: {
|
||||
enableTranslate: true
|
||||
},
|
||||
form: {
|
||||
roomId: parseInt(window.localStorage.roomId || '1'),
|
||||
...config.getLocalConfig()
|
||||
},
|
||||
roomUrl: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
roomUrl() {
|
||||
if (this.form.roomId === '') {
|
||||
return ''
|
||||
}
|
||||
let query = {...this.form}
|
||||
delete query.roomId
|
||||
let resolved = this.$router.resolve({name: 'room', params: {roomId: this.form.roomId}, query})
|
||||
return `${window.location.protocol}//${window.location.host}${resolved.href}`
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
roomUrl: _.debounce(function() {
|
||||
window.localStorage.roomId = this.form.roomId
|
||||
config.setLocalConfig(this.form)
|
||||
}, 500)
|
||||
},
|
||||
mounted() {
|
||||
this.updateServerConfig()
|
||||
},
|
||||
methods: {
|
||||
saveConfig() {
|
||||
this.$refs.form.validate(async valid => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
window.localStorage.roomId = this.form.roomId
|
||||
config.setLocalConfig(this.form)
|
||||
|
||||
try {
|
||||
if (window.localStorage.configId) {
|
||||
try {
|
||||
await config.setRemoteConfig(window.localStorage.configId, this.form)
|
||||
} catch (e) { // 404
|
||||
window.localStorage.configId = (await config.createRemoteConfig(this.form)).id
|
||||
}
|
||||
} else {
|
||||
window.localStorage.configId = (await config.createRemoteConfig(this.form)).id
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error(this.$t('home.failedToSave') + e)
|
||||
return
|
||||
}
|
||||
this.$message({message: this.$t('home.successfullySaved'), type: 'success'})
|
||||
|
||||
let resolved = this.$router.resolve({name: 'room', params: {roomId: this.form.roomId},
|
||||
query: {config_id: window.localStorage.configId}})
|
||||
this.roomUrl = `http://${window.location.host}${resolved.href}`
|
||||
})
|
||||
async updateServerConfig() {
|
||||
try {
|
||||
this.serverConfig = (await axios.get(`/server_info`)).data.config
|
||||
} catch (e) {
|
||||
this.$message.error('Failed to fetch server information: ' + e)
|
||||
}
|
||||
},
|
||||
enterRoom() {
|
||||
window.open(this.roomUrl, `room ${this.form.roomId}`, 'menubar=0,location=0,scrollbars=0,toolbar=0,width=600,height=600')
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<template>
|
||||
<chat-renderer :paidMessages="paidMessages" :messages="messages" :css="config.css"></chat-renderer>
|
||||
<chat-renderer ref="renderer" :maxNumber="config.maxNumber" :showGiftName="config.showGiftName"></chat-renderer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import config from '@/api/config'
|
||||
import {mergeConfig, toBool, toInt} from '@/utils'
|
||||
import * as config from '@/api/config'
|
||||
import ChatRenderer from '@/components/ChatRenderer'
|
||||
import * as constants from '@/components/ChatRenderer/constants'
|
||||
|
||||
// const COMMAND_HEARTBEAT = 0
|
||||
const COMMAND_HEARTBEAT = 0
|
||||
const COMMAND_JOIN_ROOM = 1
|
||||
const COMMAND_ADD_TEXT = 2
|
||||
const COMMAND_ADD_GIFT = 3
|
||||
const COMMAND_ADD_MEMBER = 4
|
||||
const COMMAND_ADD_SUPER_CHAT = 5
|
||||
const COMMAND_DEL_SUPER_CHAT = 6
|
||||
const COMMAND_UPDATE_TRANSLATION = 7
|
||||
|
||||
export default {
|
||||
name: 'Room',
|
||||
@@ -21,82 +23,136 @@ export default {
|
||||
ChatRenderer
|
||||
},
|
||||
data() {
|
||||
let cfg = {...config.DEFAULT_CONFIG}
|
||||
cfg.blockKeywords = cfg.blockKeywords.split('\n').filter(val => val)
|
||||
cfg.blockUsers = cfg.blockUsers.split('\n').filter(val => val)
|
||||
cfg.maxSpeed = 0
|
||||
return {
|
||||
config: cfg,
|
||||
config: {...config.DEFAULT_CONFIG},
|
||||
|
||||
websocket: null,
|
||||
messagesBufferTimerId: null,
|
||||
nextId: 0,
|
||||
messagesBuffer: [], // 暂时不显示的消息,可能会丢弃
|
||||
messages: [], // 正在显示的消息
|
||||
paidMessages: []
|
||||
retryCount: 0,
|
||||
isDestroying: false,
|
||||
heartbeatTimerId: null
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
// 开发时使用localhost:12450
|
||||
const url = process.env.NODE_ENV === 'development' ? 'ws://localhost:12450/chat' : `ws://${window.location.host}/chat`
|
||||
this.websocket = new WebSocket(url)
|
||||
this.websocket.onopen = this.onWsOpen
|
||||
this.websocket.onmessage = this.onWsMessage
|
||||
|
||||
if (this.$route.query.config_id) {
|
||||
try {
|
||||
let cfg = await config.getRemoteConfig(this.$route.query.config_id)
|
||||
cfg.blockKeywords = cfg.blockKeywords.split('\n').filter(val => val)
|
||||
cfg.blockUsers = cfg.blockUsers.split('\n').filter(val => val)
|
||||
this.config = cfg
|
||||
} catch (e) {
|
||||
this.$message.error('获取配置失败:' + e)
|
||||
}
|
||||
computed: {
|
||||
blockKeywords() {
|
||||
return this.config.blockKeywords.split('\n').filter(val => val)
|
||||
},
|
||||
blockUsers() {
|
||||
return this.config.blockUsers.split('\n').filter(val => val)
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.updateConfig()
|
||||
this.wsConnect()
|
||||
// 提示用户已加载
|
||||
this.$message({
|
||||
message: 'Loaded',
|
||||
duration: '500'
|
||||
})
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.messagesBufferTimerId) {
|
||||
window.clearInterval(this.messagesBufferTimerId)
|
||||
}
|
||||
this.isDestroying = true
|
||||
this.websocket.close()
|
||||
},
|
||||
watch: {
|
||||
config(val) {
|
||||
if (this.messagesBufferTimerId) {
|
||||
window.clearInterval(this.messagesBufferTimerId)
|
||||
this.messagesBufferTimerId = null
|
||||
}
|
||||
if (val.maxSpeed > 0) {
|
||||
this.messagesBufferTimerId = window.setInterval(this.handleMessagesBuffer.bind(this), 1000 / val.maxSpeed)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateConfig() {
|
||||
let cfg = {}
|
||||
// 留空的使用默认值
|
||||
for (let i in this.$route.query) {
|
||||
if (this.$route.query[i] !== '') {
|
||||
cfg[i] = this.$route.query[i]
|
||||
}
|
||||
}
|
||||
cfg = mergeConfig(cfg, config.DEFAULT_CONFIG)
|
||||
|
||||
cfg.minGiftPrice = toInt(cfg.minGiftPrice, config.DEFAULT_CONFIG.minGiftPrice)
|
||||
cfg.showDanmaku = toBool(cfg.showDanmaku)
|
||||
cfg.showGift = toBool(cfg.showGift)
|
||||
cfg.showGiftName = toBool(cfg.showGiftName)
|
||||
cfg.mergeSimilarDanmaku = toBool(cfg.mergeSimilarDanmaku)
|
||||
cfg.mergeGift = toBool(cfg.mergeGift)
|
||||
cfg.maxNumber = toInt(cfg.maxNumber, config.DEFAULT_CONFIG.maxNumber)
|
||||
cfg.blockGiftDanmaku = toBool(cfg.blockGiftDanmaku)
|
||||
cfg.blockLevel = toInt(cfg.blockLevel, config.DEFAULT_CONFIG.blockLevel)
|
||||
cfg.blockNewbie = toBool(cfg.blockNewbie)
|
||||
cfg.blockNotMobileVerified = toBool(cfg.blockNotMobileVerified)
|
||||
cfg.blockMedalLevel = toInt(cfg.blockMedalLevel, config.DEFAULT_CONFIG.blockMedalLevel)
|
||||
cfg.autoTranslate = toBool(cfg.autoTranslate)
|
||||
|
||||
this.config = cfg
|
||||
},
|
||||
wsConnect() {
|
||||
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}/chat`
|
||||
this.websocket = new WebSocket(url)
|
||||
this.websocket.onopen = this.onWsOpen
|
||||
this.websocket.onclose = this.onWsClose
|
||||
this.websocket.onmessage = this.onWsMessage
|
||||
this.heartbeatTimerId = window.setInterval(this.sendHeartbeat, 10 * 1000)
|
||||
},
|
||||
sendHeartbeat() {
|
||||
this.websocket.send(JSON.stringify({
|
||||
cmd: COMMAND_HEARTBEAT
|
||||
}))
|
||||
},
|
||||
onWsOpen() {
|
||||
this.retryCount = 0
|
||||
this.websocket.send(JSON.stringify({
|
||||
cmd: COMMAND_JOIN_ROOM,
|
||||
data: {
|
||||
roomId: parseInt(this.$route.params.roomId)
|
||||
roomId: parseInt(this.$route.params.roomId),
|
||||
config: {
|
||||
autoTranslate: this.config.autoTranslate
|
||||
}
|
||||
}
|
||||
}))
|
||||
},
|
||||
onWsClose() {
|
||||
if (this.heartbeatTimerId) {
|
||||
window.clearInterval(this.heartbeatTimerId)
|
||||
this.heartbeatTimerId = null
|
||||
}
|
||||
if (this.isDestroying) {
|
||||
return
|
||||
}
|
||||
window.console.log(`掉线重连中${++this.retryCount}`)
|
||||
this.wsConnect()
|
||||
},
|
||||
onWsMessage(event) {
|
||||
let {cmd, data} = JSON.parse(event.data)
|
||||
let message = null
|
||||
let time = data.timestamp ? new Date(data.timestamp * 1000) : new Date()
|
||||
switch(cmd) {
|
||||
switch (cmd) {
|
||||
case COMMAND_ADD_TEXT:
|
||||
if (!this.config.showDanmaku || !this.filterTextMessage(data) || this.mergeSimilar(data.content)) {
|
||||
data = {
|
||||
avatarUrl: data[0],
|
||||
timestamp: data[1],
|
||||
authorName: data[2],
|
||||
authorType: data[3],
|
||||
content: data[4],
|
||||
privilegeType: data[5],
|
||||
isGiftDanmaku: !!data[6],
|
||||
authorLevel: data[7],
|
||||
isNewbie: !!data[8],
|
||||
isMobileVerified: !!data[9],
|
||||
medalLevel: data[10],
|
||||
id: data[11],
|
||||
translation: data[12]
|
||||
}
|
||||
if (!this.config.showDanmaku || !this.filterTextMessage(data) || this.mergeSimilarText(data.content)) {
|
||||
break
|
||||
}
|
||||
message = {
|
||||
id: data.id,
|
||||
type: constants.MESSAGE_TYPE_TEXT,
|
||||
avatarUrl: data.avatarUrl,
|
||||
time: time,
|
||||
time: new Date(data.timestamp * 1000),
|
||||
authorName: data.authorName,
|
||||
authorType: data.authorType,
|
||||
content: data.content,
|
||||
privilegeType: data.privilegeType,
|
||||
repeated: 1
|
||||
repeated: 1,
|
||||
translation: data.translation
|
||||
}
|
||||
break
|
||||
case COMMAND_ADD_GIFT: {
|
||||
@@ -104,54 +160,73 @@ export default {
|
||||
break
|
||||
}
|
||||
let price = data.totalCoin / 1000
|
||||
if (this.mergeSimilarGift(data.authorName, price, data.giftName, data.num)) {
|
||||
break
|
||||
}
|
||||
if (price < this.config.minGiftPrice) { // 丢人
|
||||
break
|
||||
}
|
||||
message = {
|
||||
type: constants.MESSAGE_TYPE_SUPER_CHAT,
|
||||
id: data.id,
|
||||
type: constants.MESSAGE_TYPE_GIFT,
|
||||
avatarUrl: data.avatarUrl,
|
||||
time: new Date(data.timestamp * 1000),
|
||||
authorName: data.authorName,
|
||||
price: price,
|
||||
time: time,
|
||||
content: '' // 有了SC,礼物不需要内容了
|
||||
giftName: data.giftName,
|
||||
num: data.num
|
||||
}
|
||||
break
|
||||
}
|
||||
case COMMAND_ADD_MEMBER:
|
||||
if (!this.config.showGift || !this.filterSuperChatMessage(data)) {
|
||||
if (!this.config.showGift || !this.filterNewMemberMessage(data)) {
|
||||
break
|
||||
}
|
||||
message = {
|
||||
id: data.id,
|
||||
type: constants.MESSAGE_TYPE_MEMBER,
|
||||
avatarUrl: data.avatarUrl,
|
||||
time: time,
|
||||
time: new Date(data.timestamp * 1000),
|
||||
authorName: data.authorName,
|
||||
title: 'NEW MEMBER!',
|
||||
content: `Welcome ${data.authorName}!`
|
||||
}
|
||||
break
|
||||
case COMMAND_ADD_SUPER_CHAT:
|
||||
if (!this.config.showGift) {
|
||||
if (!this.config.showGift || !this.filterSuperChatMessage(data)) {
|
||||
break
|
||||
}
|
||||
if (data.price < this.config.minGiftPrice) { // 丢人
|
||||
break
|
||||
}
|
||||
message = {
|
||||
id: data.id,
|
||||
type: constants.MESSAGE_TYPE_SUPER_CHAT,
|
||||
avatarUrl: data.avatarUrl,
|
||||
authorName: data.authorName,
|
||||
price: data.price,
|
||||
time: time,
|
||||
content: data.content
|
||||
time: new Date(data.timestamp * 1000),
|
||||
content: data.content.trim()
|
||||
}
|
||||
break
|
||||
case COMMAND_DEL_SUPER_CHAT:
|
||||
// TODO 删除SC
|
||||
for (let id of data.ids) {
|
||||
this.$refs.renderer.delMessage(id)
|
||||
}
|
||||
break
|
||||
case COMMAND_UPDATE_TRANSLATION:
|
||||
if (!this.config.autoTranslate) {
|
||||
break
|
||||
}
|
||||
data = {
|
||||
id: data[0],
|
||||
translation: data[1]
|
||||
}
|
||||
this.$refs.renderer.updateMessage(data.id, {translation: data.translation})
|
||||
break
|
||||
}
|
||||
if (message) {
|
||||
this.addMessageBuffer(message)
|
||||
this.$refs.renderer.addMessage(message)
|
||||
}
|
||||
},
|
||||
filterTextMessage(data) {
|
||||
@@ -166,90 +241,35 @@ export default {
|
||||
} else if (this.config.blockMedalLevel > 0 && data.medalLevel < this.config.blockMedalLevel) {
|
||||
return false
|
||||
}
|
||||
for (let keyword of this.config.blockKeywords) {
|
||||
if (data.content.indexOf(keyword) !== -1) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for (let user of this.config.blockUsers) {
|
||||
if (data.authorName === user) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
return this.filterSuperChatMessage(data)
|
||||
},
|
||||
filterSuperChatMessage(data) {
|
||||
for (let keyword of this.config.blockKeywords) {
|
||||
for (let keyword of this.blockKeywords) {
|
||||
if (data.content.indexOf(keyword) !== -1) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for (let user of this.config.blockUsers) {
|
||||
return this.filterNewMemberMessage(data)
|
||||
},
|
||||
filterNewMemberMessage(data) {
|
||||
for (let user of this.blockUsers) {
|
||||
if (data.authorName === user) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
mergeSimilar(content) {
|
||||
mergeSimilarText(content) {
|
||||
if (!this.config.mergeSimilarDanmaku) {
|
||||
return false
|
||||
}
|
||||
for (let i = this.messages.length - 1; i >= 0 && i >= this.messages.length - 5; i--) {
|
||||
let message = this.messages[i]
|
||||
if (
|
||||
(message.content.indexOf(content) !== -1 || content.indexOf(message.content) !== -1) // 包含对方
|
||||
&& Math.abs(message.content.length - content.length) < Math.min(message.content.length, content.length) // 长度差比两者长度都小
|
||||
) {
|
||||
message.repeated++
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return this.$refs.renderer.mergeSimilarText(content)
|
||||
},
|
||||
addMessageBuffer(message) {
|
||||
if (this.config.maxSpeed > 0 && message.type === 0) {
|
||||
message.addTime = new Date()
|
||||
this.messagesBuffer.push(message)
|
||||
} else {
|
||||
// 无速度限制或者是礼物
|
||||
this.addMessageShow(message)
|
||||
mergeSimilarGift(authorName, price, giftName, num) {
|
||||
if (!this.config.mergeGift) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
addMessageShow(message) {
|
||||
message.id = this.nextId++
|
||||
message.addTime = new Date()
|
||||
this.messages.push(message)
|
||||
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
|
||||
this.paidMessages.unshift(message)
|
||||
}
|
||||
// 防止同时添加和删除项目时所有的项目重新渲染 https://github.com/vuejs/vue/issues/6857
|
||||
this.$nextTick(() => {
|
||||
if (this.messages.length > this.config.maxNumber) {
|
||||
this.messages.splice(0, this.messages.length - this.config.maxNumber)
|
||||
}
|
||||
})
|
||||
},
|
||||
handleMessagesBuffer() {
|
||||
// 3秒内未发出则丢弃
|
||||
let i
|
||||
let curTime = new Date()
|
||||
for (i = 0; i < this.messagesBuffer.length; i++) {
|
||||
if (curTime - this.messagesBuffer[i].addTime <= 3 * 1000) {
|
||||
break
|
||||
}
|
||||
}
|
||||
this.messagesBuffer.splice(0, i)
|
||||
if (this.messagesBuffer.length <= 0) {
|
||||
return
|
||||
}
|
||||
let message = this.messagesBuffer.shift()
|
||||
// 防止短时间发送多条时不能合并
|
||||
if (this.mergeSimilar(message.content)) {
|
||||
this.handleMessagesBuffer()
|
||||
return
|
||||
}
|
||||
this.addMessageShow(message)
|
||||
return this.$refs.renderer.mergeSimilarGift(authorName, price, giftName, num)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,9 +188,9 @@
|
||||
</el-form>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div id="example-container" ref="exampleContainer">
|
||||
<div ref="exampleContainer" id="example-container">
|
||||
<div id="fakebody">
|
||||
<chat-renderer :paidMessages="examplePaidMessages" :messages="messages" :css="exampleCss" ref="example"></chat-renderer>
|
||||
<chat-renderer ref="renderer" :css="exampleCss"></chat-renderer>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
@@ -200,8 +200,8 @@
|
||||
<script>
|
||||
import _ from 'lodash'
|
||||
|
||||
import stylegen from './stylegen'
|
||||
import fonts from './fonts'
|
||||
import * as stylegen from './stylegen'
|
||||
import * as fonts from './fonts'
|
||||
import ChatRenderer from '@/components/ChatRenderer'
|
||||
import * as constants from '@/components/ChatRenderer/constants'
|
||||
|
||||
@@ -216,7 +216,8 @@ let textMessageTemplate = {
|
||||
authorType: constants.AUTHRO_TYPE_NORMAL,
|
||||
content: '',
|
||||
privilegeType: 0,
|
||||
repeated: 1
|
||||
repeated: 1,
|
||||
translation: ''
|
||||
}
|
||||
let legacyPaidMessageTemplate = {
|
||||
id: 0,
|
||||
@@ -236,61 +237,62 @@ let paidMessageTemplate = {
|
||||
authorName: '',
|
||||
price: 0,
|
||||
time: time,
|
||||
content: ''
|
||||
content: '',
|
||||
translation: ''
|
||||
}
|
||||
let nextId = 0
|
||||
const EXAMPLE_MESSAGES = [
|
||||
{
|
||||
...textMessageTemplate,
|
||||
id: nextId++,
|
||||
id: (nextId++).toString(),
|
||||
authorName: 'mob路人',
|
||||
content: '8888888888',
|
||||
repeated: 12
|
||||
}, {
|
||||
},
|
||||
{
|
||||
...textMessageTemplate,
|
||||
id: nextId++,
|
||||
id: (nextId++).toString(),
|
||||
authorName: 'member舰长',
|
||||
authorType: constants.AUTHRO_TYPE_MEMBER,
|
||||
content: '草',
|
||||
privilegeType: 3,
|
||||
repeated: 3
|
||||
}, {
|
||||
},
|
||||
{
|
||||
...textMessageTemplate,
|
||||
id: nextId++,
|
||||
id: (nextId++).toString(),
|
||||
authorName: 'admin房管',
|
||||
authorType: constants.AUTHRO_TYPE_ADMIN,
|
||||
content: 'kksk'
|
||||
}, {
|
||||
},
|
||||
{
|
||||
...legacyPaidMessageTemplate,
|
||||
id: nextId++,
|
||||
authorName: '吾乐KANA',
|
||||
content: 'Welcome 吾乐KANA!'
|
||||
}, {
|
||||
id: (nextId++).toString(),
|
||||
authorName: '进击的冰糖',
|
||||
content: 'Welcome 进击的冰糖!'
|
||||
},
|
||||
{
|
||||
...paidMessageTemplate,
|
||||
id: nextId++,
|
||||
id: (nextId++).toString(),
|
||||
authorName: '无火的残渣',
|
||||
price: 66600,
|
||||
content: 'Sent 小电视飞船x100'
|
||||
}, {
|
||||
},
|
||||
{
|
||||
...textMessageTemplate,
|
||||
id: nextId++,
|
||||
id: (nextId++).toString(),
|
||||
authorName: 'streamer主播',
|
||||
authorType: constants.AUTHRO_TYPE_OWNER,
|
||||
content: '感谢石油佬送的小电视'
|
||||
}, {
|
||||
content: '老板大气,老板身体健康'
|
||||
},
|
||||
{
|
||||
...paidMessageTemplate,
|
||||
id: nextId++,
|
||||
id: (nextId++).toString(),
|
||||
authorName: '夏色祭保護協会会長',
|
||||
price: 28,
|
||||
content: 'Sent 礼花x1'
|
||||
price: 30,
|
||||
content: '言いたいことがあるんだよ!'
|
||||
}
|
||||
]
|
||||
let examplePaidMessages = []
|
||||
for (let message of EXAMPLE_MESSAGES) {
|
||||
if (message.type !== constants.MESSAGE_TYPE_TEXT) {
|
||||
examplePaidMessages.push(message)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'StyleGenerator',
|
||||
@@ -298,15 +300,14 @@ export default {
|
||||
ChatRenderer
|
||||
},
|
||||
data() {
|
||||
let config = stylegen.getLocalConfig()
|
||||
let result = stylegen.getStyle(config)
|
||||
let stylegenConfig = stylegen.getLocalConfig()
|
||||
let result = stylegen.getStyle(stylegenConfig)
|
||||
return {
|
||||
FONTS: [...fonts.LOCAL_FONTS, ...fonts.NETWORK_FONTS],
|
||||
form: {...config},
|
||||
|
||||
form: {...stylegenConfig},
|
||||
result,
|
||||
exampleCss: result.replace(/^body\b/gm, '#fakebody'),
|
||||
messages: EXAMPLE_MESSAGES,
|
||||
examplePaidMessages
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -314,8 +315,19 @@ export default {
|
||||
return stylegen.getStyle(this.form)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
computedResult: _.debounce(function(val) {
|
||||
this.result = val
|
||||
stylegen.setLocalConfig(this.form)
|
||||
}, 500),
|
||||
result(val) {
|
||||
this.exampleCss = val.replace(/^body\b/gm, '#fakebody')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
let observer = new MutationObserver(() => this.$refs.example.scrollToBottom())
|
||||
this.$refs.renderer.addMessages(EXAMPLE_MESSAGES)
|
||||
|
||||
let observer = new MutationObserver(() => this.$refs.renderer.scrollToBottom())
|
||||
observer.observe(this.$refs.exampleContainer, {attributes: true})
|
||||
},
|
||||
methods: {
|
||||
@@ -330,8 +342,8 @@ export default {
|
||||
callback(res)
|
||||
},
|
||||
playAnimation() {
|
||||
this.messages = []
|
||||
this.$nextTick(() => this.messages = EXAMPLE_MESSAGES)
|
||||
this.$refs.renderer.clearMessages()
|
||||
this.$nextTick(() => this.$refs.renderer.addMessages(EXAMPLE_MESSAGES))
|
||||
},
|
||||
copyResult() {
|
||||
this.$refs.result.select()
|
||||
@@ -340,15 +352,6 @@ export default {
|
||||
resetConfig() {
|
||||
this.form = {...stylegen.DEFAULT_CONFIG}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
computedResult: _.debounce(function(val) {
|
||||
this.result = val
|
||||
stylegen.setLocalConfig(this.form)
|
||||
}, 500),
|
||||
result(val) {
|
||||
this.exampleCss = val.replace(/^body\b/gm, '#fakebody')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {mergeConfig} from '@/utils'
|
||||
import fonts from './fonts'
|
||||
import * as fonts from './fonts'
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
showOutlines: true,
|
||||
@@ -404,10 +404,3 @@ yt-live-chat-legacy-paid-message-renderer {
|
||||
animation-fill-mode: both;
|
||||
}`
|
||||
}
|
||||
|
||||
export default {
|
||||
DEFAULT_CONFIG,
|
||||
setLocalConfig,
|
||||
getLocalConfig,
|
||||
getStyle
|
||||
}
|
||||
|
||||
65
main.py
@@ -1,7 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import webbrowser
|
||||
@@ -9,55 +8,77 @@ import webbrowser
|
||||
import tornado.ioloop
|
||||
import tornado.web
|
||||
|
||||
import api.chat
|
||||
import api.main
|
||||
import config
|
||||
import models.avatar
|
||||
import models.database
|
||||
import models.translate
|
||||
import update
|
||||
import views.chat
|
||||
import views.config
|
||||
import views.main
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WEB_ROOT = os.path.join(os.path.dirname(__file__), 'frontend', 'dist')
|
||||
|
||||
routes = [
|
||||
(r'/server_info', api.main.ServerInfoHandler),
|
||||
(r'/chat', api.chat.ChatHandler),
|
||||
|
||||
(r'/((css|fonts|img|js|static)/.*)', tornado.web.StaticFileHandler, {'path': WEB_ROOT}),
|
||||
(r'/(favicon\.ico)', tornado.web.StaticFileHandler, {'path': WEB_ROOT}),
|
||||
(r'/.*', api.main.MainHandler, {'path': WEB_ROOT})
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
init_logging(args.debug)
|
||||
config.init()
|
||||
models.database.init(args.debug)
|
||||
models.avatar.init()
|
||||
models.translate.init()
|
||||
api.chat.init()
|
||||
update.check_update()
|
||||
|
||||
run_server(args.host, args.port, args.debug)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description='用于OBS的仿YouTube风格的bilibili直播聊天层')
|
||||
parser.add_argument('--host', help='服务器host,默认为127.0.0.1', default='127.0.0.1')
|
||||
parser.add_argument('--port', help='服务器端口,默认为12450', type=int, default=12450)
|
||||
parser.add_argument('--debug', help='调试模式', action='store_true')
|
||||
args = parser.parse_args()
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def init_logging(debug):
|
||||
logging.basicConfig(
|
||||
format='{asctime} {levelname} [{name}]: {message}',
|
||||
datefmt='%Y-%m-%d %H:%M:%S',
|
||||
style='{',
|
||||
level=logging.INFO if not args.debug else logging.DEBUG
|
||||
level=logging.INFO if not debug else logging.DEBUG
|
||||
)
|
||||
|
||||
asyncio.ensure_future(update.check_update())
|
||||
|
||||
def run_server(host, port, debug):
|
||||
app = tornado.web.Application(
|
||||
[
|
||||
(r'/chat', views.chat.ChatHandler),
|
||||
(r'/config', views.config.ConfigsHandler),
|
||||
(r'/config/(.+)', views.config.ConfigHandler),
|
||||
|
||||
(r'/((css|fonts|img|js|static)/.*)', tornado.web.StaticFileHandler, {'path': WEB_ROOT}),
|
||||
(r'/(favicon\.ico)', tornado.web.StaticFileHandler, {'path': WEB_ROOT}),
|
||||
(r'/.*', views.main.MainHandler, {'path': WEB_ROOT})
|
||||
],
|
||||
websocket_ping_interval=30,
|
||||
debug=args.debug,
|
||||
routes,
|
||||
websocket_ping_interval=10,
|
||||
debug=debug,
|
||||
autoreload=False
|
||||
)
|
||||
try:
|
||||
app.listen(args.port, args.host)
|
||||
app.listen(port, host)
|
||||
except OSError:
|
||||
logger.warning('Address is used %s:%d', args.host, args.port)
|
||||
logger.warning('Address is used %s:%d', host, port)
|
||||
return
|
||||
finally:
|
||||
url = 'http://localhost' if args.port == 80 else f'http://localhost:{args.port}'
|
||||
url = 'http://localhost/' if port == 80 else f'http://localhost:{port}/'
|
||||
# 防止更新版本后浏览器加载缓存
|
||||
url += '?_v=' + update.VERSION
|
||||
webbrowser.open(url)
|
||||
logger.info('Server started: %s:%d', args.host, args.port)
|
||||
logger.info('Server started: %s:%d', host, port)
|
||||
tornado.ioloop.IOLoop.current().start()
|
||||
|
||||
|
||||
|
||||
205
models/avatar.py
Normal file
@@ -0,0 +1,205 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
from typing import *
|
||||
|
||||
import aiohttp
|
||||
import sqlalchemy
|
||||
import sqlalchemy.exc
|
||||
|
||||
import models.database
|
||||
|
||||
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()
|
||||
# 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 = asyncio.Queue(15)
|
||||
# 上次被B站ban时间
|
||||
_last_fetch_banned_time: Optional[datetime.datetime] = None
|
||||
|
||||
|
||||
def init():
|
||||
asyncio.ensure_future(_get_avatar_url_from_web_consumer())
|
||||
|
||||
|
||||
async def get_avatar_url(user_id):
|
||||
avatar_url = get_avatar_url_from_memory(user_id)
|
||||
if avatar_url is not None:
|
||||
return avatar_url
|
||||
avatar_url = await get_avatar_url_from_database(user_id)
|
||||
if avatar_url is not None:
|
||||
return avatar_url
|
||||
avatar_url = await get_avatar_url_from_web(user_id)
|
||||
if avatar_url is not None:
|
||||
return avatar_url
|
||||
return DEFAULT_AVATAR_URL
|
||||
|
||||
|
||||
def get_avatar_url_from_memory(user_id):
|
||||
return _avatar_url_cache.get(user_id, None)
|
||||
|
||||
|
||||
def get_avatar_url_from_database(user_id) -> Awaitable[Optional[str]]:
|
||||
return asyncio.get_event_loop().run_in_executor(
|
||||
None, _do_get_avatar_url_from_database, user_id
|
||||
)
|
||||
|
||||
|
||||
def _do_get_avatar_url_from_database(user_id):
|
||||
try:
|
||||
with models.database.get_session() as session:
|
||||
user = session.query(BilibiliUser).filter(BilibiliUser.uid == user_id).one_or_none()
|
||||
if user is None:
|
||||
return None
|
||||
avatar_url = user.avatar_url
|
||||
|
||||
# 如果离上次更新太久就更新所有缓存
|
||||
if (datetime.datetime.now() - user.update_time).days >= 3:
|
||||
def refresh_cache():
|
||||
_avatar_url_cache.pop(user_id, None)
|
||||
get_avatar_url_from_web(user_id)
|
||||
|
||||
_main_event_loop.call_soon(refresh_cache)
|
||||
else:
|
||||
# 否则只更新内存缓存
|
||||
_update_avatar_cache_in_memory(user_id, avatar_url)
|
||||
except sqlalchemy.exc.OperationalError:
|
||||
# SQLite会锁整个文件,忽略就行
|
||||
return None
|
||||
except sqlalchemy.exc.SQLAlchemyError:
|
||||
logger.exception('_do_get_avatar_url_from_database failed:')
|
||||
return None
|
||||
return avatar_url
|
||||
|
||||
|
||||
def get_avatar_url_from_web(user_id) -> Awaitable[Optional[str]]:
|
||||
# 如果已有正在获取的future则返回,防止重复获取同一个uid
|
||||
future = _uid_fetch_future_map.get(user_id, None)
|
||||
if future is not None:
|
||||
return future
|
||||
# 否则创建一个获取任务
|
||||
_uid_fetch_future_map[user_id] = future = _main_event_loop.create_future()
|
||||
future.add_done_callback(lambda _future: _uid_fetch_future_map.pop(user_id, None))
|
||||
try:
|
||||
_uid_queue_to_fetch.put_nowait(user_id)
|
||||
except asyncio.QueueFull:
|
||||
future.set_result(None)
|
||||
return future
|
||||
|
||||
|
||||
async def _get_avatar_url_from_web_consumer():
|
||||
while True:
|
||||
try:
|
||||
user_id = await _uid_queue_to_fetch.get()
|
||||
future = _uid_fetch_future_map.get(user_id, None)
|
||||
if future is None:
|
||||
continue
|
||||
|
||||
# 防止在被ban的时候获取
|
||||
global _last_fetch_banned_time
|
||||
if _last_fetch_banned_time is not None:
|
||||
cur_time = datetime.datetime.now()
|
||||
if (cur_time - _last_fetch_banned_time).total_seconds() < 3 * 60 + 3:
|
||||
# 3分钟以内被ban,解封大约要15分钟
|
||||
future.set_result(None)
|
||||
continue
|
||||
else:
|
||||
_last_fetch_banned_time = None
|
||||
|
||||
asyncio.ensure_future(_get_avatar_url_from_web_coroutine(user_id, future))
|
||||
|
||||
# 限制频率,防止被B站ban
|
||||
await asyncio.sleep(0.2)
|
||||
except:
|
||||
logger.exception('_get_avatar_url_from_web_consumer error:')
|
||||
|
||||
|
||||
async def _get_avatar_url_from_web_coroutine(user_id, future):
|
||||
try:
|
||||
avatar_url = await _do_get_avatar_url_from_web(user_id)
|
||||
except BaseException as e:
|
||||
future.set_exception(e)
|
||||
else:
|
||||
future.set_result(avatar_url)
|
||||
|
||||
|
||||
async def _do_get_avatar_url_from_web(user_id):
|
||||
try:
|
||||
async with _http_session.get('https://api.bilibili.com/x/space/acc/info',
|
||||
params={'mid': user_id}) as r:
|
||||
if r.status != 200:
|
||||
logger.warning('Failed to fetch avatar: status=%d %s uid=%d', r.status, r.reason, user_id)
|
||||
if r.status == 412:
|
||||
# 被B站ban了
|
||||
global _last_fetch_banned_time
|
||||
_last_fetch_banned_time = datetime.datetime.now()
|
||||
return None
|
||||
data = await r.json()
|
||||
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
||||
return None
|
||||
|
||||
avatar_url = process_avatar_url(data['data']['face'])
|
||||
update_avatar_cache(user_id, avatar_url)
|
||||
return avatar_url
|
||||
|
||||
|
||||
def process_avatar_url(avatar_url):
|
||||
# 去掉协议,兼容HTTP、HTTPS
|
||||
m = re.fullmatch(r'(?:https?:)?(.*)', avatar_url)
|
||||
if m is not None:
|
||||
avatar_url = m[1]
|
||||
# 缩小图片加快传输
|
||||
if not avatar_url.endswith('noface.gif'):
|
||||
avatar_url += '@48w_48h'
|
||||
return avatar_url
|
||||
|
||||
|
||||
def update_avatar_cache(user_id, avatar_url):
|
||||
_update_avatar_cache_in_memory(user_id, avatar_url)
|
||||
asyncio.get_event_loop().run_in_executor(
|
||||
None, _update_avatar_cache_in_database, user_id, avatar_url
|
||||
)
|
||||
|
||||
|
||||
def _update_avatar_cache_in_memory(user_id, avatar_url):
|
||||
_avatar_url_cache[user_id] = avatar_url
|
||||
if len(_avatar_url_cache) > 50000:
|
||||
for _, key in zip(range(100), _avatar_url_cache):
|
||||
del _avatar_url_cache[key]
|
||||
|
||||
|
||||
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()
|
||||
if user is None:
|
||||
user = BilibiliUser(uid=user_id, avatar_url=avatar_url,
|
||||
update_time=datetime.datetime.now())
|
||||
session.add(user)
|
||||
else:
|
||||
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.Text)
|
||||
update_time = sqlalchemy.Column(sqlalchemy.DateTime)
|
||||
34
models/database.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import contextlib
|
||||
from typing import *
|
||||
|
||||
import sqlalchemy.ext.declarative
|
||||
import sqlalchemy.orm
|
||||
|
||||
import config
|
||||
|
||||
OrmBase = sqlalchemy.ext.declarative.declarative_base()
|
||||
engine = None
|
||||
DbSession: Optional[Type[sqlalchemy.orm.Session]] = None
|
||||
|
||||
|
||||
def init(debug):
|
||||
cfg = config.get_config()
|
||||
global engine, DbSession
|
||||
engine = sqlalchemy.create_engine(cfg.database_url, echo=debug)
|
||||
DbSession = sqlalchemy.orm.sessionmaker(bind=engine)
|
||||
|
||||
OrmBase.metadata.create_all(engine)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def get_session():
|
||||
session = DbSession()
|
||||
try:
|
||||
yield session
|
||||
except:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
392
models/translate.py
Normal file
@@ -0,0 +1,392 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import hashlib
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
|
||||
import yarl
|
||||
from typing import *
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NO_TRANSLATE_TEXTS = {
|
||||
'草', '草草', '草草草', '草生', '大草原', '上手', '上手上手', '理解', '理解理解', '天才', '天才天才',
|
||||
'强', '余裕', '余裕余裕', '大丈夫', '再放送', '放送事故'
|
||||
}
|
||||
|
||||
_main_event_loop = asyncio.get_event_loop()
|
||||
_http_session = aiohttp.ClientSession()
|
||||
_translate_providers: List['TranslateProvider'] = []
|
||||
# text -> res
|
||||
_translate_cache: Dict[str, str] = {}
|
||||
# 正在翻译的Future,text -> Future
|
||||
_text_future_map: Dict[str, asyncio.Future] = {}
|
||||
|
||||
|
||||
def init():
|
||||
asyncio.ensure_future(_do_init())
|
||||
|
||||
|
||||
async def _do_init():
|
||||
# 考虑优先级
|
||||
providers = [
|
||||
TencentTranslate(),
|
||||
YoudaoTranslate(),
|
||||
BilibiliTranslate()
|
||||
]
|
||||
await asyncio.gather(*(provider.init() for provider in providers))
|
||||
global _translate_providers
|
||||
_translate_providers = providers
|
||||
|
||||
|
||||
def need_translate(text):
|
||||
text = text.strip()
|
||||
# 没有中文,平时打不出的字不管
|
||||
if not any(0x4E00 <= ord(c) <= 0x9FFF for c in text):
|
||||
return False
|
||||
# 含有日文假名
|
||||
if any(0x3040 <= ord(c) <= 0x30FF for c in text):
|
||||
return False
|
||||
# 弹幕同传
|
||||
if text.startswith('【'):
|
||||
return False
|
||||
# 中日双语
|
||||
if text in NO_TRANSLATE_TEXTS:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_translation_from_cache(text):
|
||||
key = text.strip().lower()
|
||||
return _translate_cache.get(key, None)
|
||||
|
||||
|
||||
def translate(text) -> Awaitable[Optional[str]]:
|
||||
key = text.strip().lower()
|
||||
# 如果已有正在翻译的future则返回,防止重复翻译
|
||||
future = _text_future_map.get(key, None)
|
||||
if future is not None:
|
||||
return future
|
||||
# 否则创建一个翻译任务
|
||||
future = _main_event_loop.create_future()
|
||||
|
||||
# 查缓存
|
||||
res = _translate_cache.get(key, None)
|
||||
if res is not None:
|
||||
future.set_result(res)
|
||||
return future
|
||||
|
||||
for provider in _translate_providers:
|
||||
if provider.is_available:
|
||||
_text_future_map[key] = future
|
||||
future.add_done_callback(functools.partial(_on_translate_done, key))
|
||||
provider.translate(text, future)
|
||||
return future
|
||||
|
||||
future.set_result(None)
|
||||
return future
|
||||
|
||||
|
||||
def _on_translate_done(key, future):
|
||||
_text_future_map.pop(key, None)
|
||||
# 缓存
|
||||
try:
|
||||
res = future.result()
|
||||
except:
|
||||
return
|
||||
if res is None:
|
||||
return
|
||||
_translate_cache[key] = res
|
||||
if len(_translate_cache) > 50000:
|
||||
for _, key in zip(range(100), _translate_cache):
|
||||
del _translate_cache[key]
|
||||
|
||||
|
||||
class TranslateProvider:
|
||||
async def init(self):
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
def translate(self, text, future):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class TencentTranslate(TranslateProvider):
|
||||
def __init__(self):
|
||||
self._qtv = ''
|
||||
self._qtk = ''
|
||||
# 连续失败的次数
|
||||
self._fail_count = 0
|
||||
self._cool_down_future = None
|
||||
|
||||
async def init(self):
|
||||
try:
|
||||
async with _http_session.get('https://fanyi.qq.com/') as r:
|
||||
if r.status != 200:
|
||||
logger.warning('TencentTranslate init request failed: status=%d %s', r.status, r.reason)
|
||||
return False
|
||||
html = await r.text()
|
||||
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
||||
logger.exception('TencentTranslate init error:')
|
||||
return False
|
||||
|
||||
m = re.search(r"""\bqtv\s*=\s*['"](.+?)['"]""", html)
|
||||
if m is None:
|
||||
logger.exception('TencentTranslate init failed: qtv not found')
|
||||
return False
|
||||
self._qtv = m[1]
|
||||
m = re.search(r"""\bqtk\s*=\s*['"](.+?)['"]""", html)
|
||||
if m is None:
|
||||
logger.exception('TencentTranslate init failed: qtk not found')
|
||||
return False
|
||||
self._qtk = m[1]
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_available(self):
|
||||
return self._qtv != '' and self._qtk != ''
|
||||
|
||||
def translate(self, text, future):
|
||||
asyncio.ensure_future(self._translate_coroutine(text, future))
|
||||
|
||||
async def _translate_coroutine(self, text, future):
|
||||
try:
|
||||
res = await self._do_translate(text)
|
||||
except BaseException as e:
|
||||
future.set_exception(e)
|
||||
self._on_fail()
|
||||
return
|
||||
future.set_result(res)
|
||||
if res is None:
|
||||
self._on_fail()
|
||||
else:
|
||||
self._fail_count = 0
|
||||
|
||||
async def _do_translate(self, text):
|
||||
try:
|
||||
async with _http_session.post(
|
||||
'https://fanyi.qq.com/api/translate',
|
||||
headers={
|
||||
'Referer': 'https://fanyi.qq.com/'
|
||||
},
|
||||
data={
|
||||
'source': 'zh',
|
||||
'target': 'jp',
|
||||
'sourceText': text,
|
||||
'qtv': self._qtv,
|
||||
'qtk': self._qtk
|
||||
}
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
logger.warning('TencentTranslate request failed: status=%d %s', r.status, r.reason)
|
||||
return None
|
||||
data = await r.json()
|
||||
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
||||
return None
|
||||
if data['errCode'] != 0:
|
||||
logger.warning('TencentTranslate failed: %d %s', data['errCode'], data['errMsg'])
|
||||
return None
|
||||
return ''.join(record['targetText'] for record in data['translate']['records'])
|
||||
|
||||
def _on_fail(self):
|
||||
self._fail_count += 1
|
||||
# 目前没有测试出被ban的情况,为了可靠性,连续失败20次时冷却并重新init
|
||||
if self._fail_count >= 20 and self._cool_down_future is None:
|
||||
self._cool_down_future = asyncio.ensure_future(self._cool_down())
|
||||
|
||||
async def _cool_down(self):
|
||||
logger.warning('TencentTranslate is cooling down')
|
||||
self._qtv = self._qtk = ''
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(3 * 60)
|
||||
try:
|
||||
is_success = self.init()
|
||||
except:
|
||||
logger.exception('TencentTranslate init error:')
|
||||
continue
|
||||
if is_success:
|
||||
self._fail_count = 0
|
||||
break
|
||||
finally:
|
||||
logger.warning('TencentTranslate finished cooling down')
|
||||
self._cool_down_future = None
|
||||
|
||||
|
||||
class YoudaoTranslate(TranslateProvider):
|
||||
def __init__(self):
|
||||
self._has_init = False
|
||||
self._cool_down_future = None
|
||||
|
||||
async def init(self):
|
||||
# 获取cookie
|
||||
try:
|
||||
async with _http_session.get('http://fanyi.youdao.com/') as r:
|
||||
if r.status >= 400:
|
||||
logger.warning('YoudaoTranslate init request failed: status=%d %s', r.status, r.reason)
|
||||
return False
|
||||
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
||||
return False
|
||||
|
||||
cookies = _http_session.cookie_jar.filter_cookies(yarl.URL('http://fanyi.youdao.com/'))
|
||||
res = 'JSESSIONID' in cookies and 'OUTFOX_SEARCH_USER_ID' in cookies
|
||||
if res:
|
||||
self._has_init = True
|
||||
return res
|
||||
|
||||
@property
|
||||
def is_available(self):
|
||||
return self._has_init
|
||||
|
||||
def translate(self, text, future):
|
||||
asyncio.ensure_future(self._translate_coroutine(text, future))
|
||||
|
||||
async def _translate_coroutine(self, text, future):
|
||||
try:
|
||||
res = await self._do_translate(text)
|
||||
except BaseException as e:
|
||||
future.set_exception(e)
|
||||
else:
|
||||
future.set_result(res)
|
||||
|
||||
async def _do_translate(self, text):
|
||||
try:
|
||||
async with _http_session.post(
|
||||
'http://fanyi.youdao.com/translate_o?smartresult=dict&smartresult=rule',
|
||||
headers={
|
||||
'Referer': 'http://fanyi.youdao.com/'
|
||||
},
|
||||
data={
|
||||
'i': text,
|
||||
'from': 'zh-CHS',
|
||||
'to': 'ja',
|
||||
'smartresult': 'dict',
|
||||
'client': 'fanyideskweb',
|
||||
**self._generate_salt(text),
|
||||
'doctype': 'json',
|
||||
'version': '2.1',
|
||||
'keyfrom': 'fanyi.web',
|
||||
'action': 'FY_BY_REALTlME'
|
||||
}
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
logger.warning('YoudaoTranslate request failed: status=%d %s', r.status, r.reason)
|
||||
return None
|
||||
data = await r.json()
|
||||
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
||||
return None
|
||||
except aiohttp.ContentTypeError:
|
||||
# 被ban了
|
||||
if self._cool_down_future is None:
|
||||
self._cool_down_future = asyncio.ensure_future(self._cool_down())
|
||||
return None
|
||||
if data['errorCode'] != 0:
|
||||
logger.warning('YoudaoTranslate failed: %d', data['errorCode'])
|
||||
return None
|
||||
|
||||
res = []
|
||||
for outer_result in data['translateResult']:
|
||||
for inner_result in outer_result:
|
||||
res.append(inner_result['tgt'])
|
||||
return ''.join(res)
|
||||
|
||||
@staticmethod
|
||||
def _generate_salt(text):
|
||||
timestamp = int(time.time() * 1000)
|
||||
salt = f'{timestamp}{random.randint(0, 9)}'
|
||||
md5 = hashlib.md5()
|
||||
md5.update(f'fanyideskweb{text}{salt}n%A-rKaT5fb[Gy?;N5@Tj'.encode())
|
||||
sign = md5.hexdigest()
|
||||
return {
|
||||
'ts': timestamp,
|
||||
'bv': '7bcd9ea3ff9b319782c2a557acee9179', # md5(navigator.appVersion)
|
||||
'salt': salt,
|
||||
'sign': sign
|
||||
}
|
||||
|
||||
async def _cool_down(self):
|
||||
logger.warning('YoudaoTranslate is cooling down')
|
||||
self._has_init = False
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(3 * 60)
|
||||
try:
|
||||
is_success = self.init()
|
||||
except:
|
||||
logger.exception('YoudaoTranslate init error:')
|
||||
continue
|
||||
if is_success:
|
||||
break
|
||||
finally:
|
||||
logger.warning('YoudaoTranslate finished cooling down')
|
||||
self._cool_down_future = None
|
||||
|
||||
|
||||
# 目前B站后端是百度翻译
|
||||
class BilibiliTranslate(TranslateProvider):
|
||||
def __init__(self):
|
||||
# 最长等待时间大约21秒,(text, future)
|
||||
self._text_queue = asyncio.Queue(7)
|
||||
|
||||
async def init(self):
|
||||
asyncio.ensure_future(self._translate_consumer())
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_available(self):
|
||||
return not self._text_queue.full()
|
||||
|
||||
def translate(self, text, future):
|
||||
try:
|
||||
self._text_queue.put_nowait((text, future))
|
||||
except asyncio.QueueFull:
|
||||
future.set_result(None)
|
||||
|
||||
async def _translate_consumer(self):
|
||||
while True:
|
||||
try:
|
||||
text, future = await self._text_queue.get()
|
||||
asyncio.ensure_future(self._translate_coroutine(text, future))
|
||||
# 频率限制一分钟20次
|
||||
await asyncio.sleep(3.1)
|
||||
except:
|
||||
logger.exception('BilibiliTranslate error:')
|
||||
|
||||
async def _translate_coroutine(self, text, future):
|
||||
try:
|
||||
res = await self._do_translate(text)
|
||||
except BaseException as e:
|
||||
future.set_exception(e)
|
||||
else:
|
||||
future.set_result(res)
|
||||
|
||||
@staticmethod
|
||||
async def _do_translate(text):
|
||||
try:
|
||||
async with _http_session.get(
|
||||
'https://api.live.bilibili.com/av/v1/SuperChat/messageTranslate',
|
||||
params={
|
||||
'parent_area_id': '1',
|
||||
'area_id': '199',
|
||||
'msg': text
|
||||
}
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
logger.warning('BilibiliTranslate request failed: status=%d %s', r.status, r.reason)
|
||||
return None
|
||||
data = await r.json()
|
||||
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
|
||||
return None
|
||||
if data['code'] != 0:
|
||||
logger.warning('BilibiliTranslate failed: %d %s', data['code'], data['msg'])
|
||||
return None
|
||||
return data['data']['message_trans']
|
||||
@@ -1,2 +1,3 @@
|
||||
aiohttp==3.5.4
|
||||
sqlalchemy==1.3.13
|
||||
tornado==6.0.2
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 309 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 159 KiB |
27
update.py
@@ -1,15 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import asyncio
|
||||
|
||||
import aiohttp
|
||||
|
||||
VERSION = 'v1.2.0'
|
||||
VERSION = 'v1.4.1'
|
||||
|
||||
|
||||
async def check_update():
|
||||
async with aiohttp.ClientSession() 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('New version available:', data['name'])
|
||||
print(data['body'])
|
||||
print('Download:', data['html_url'])
|
||||
def check_update():
|
||||
asyncio.ensure_future(_do_check_update())
|
||||
|
||||
|
||||
async def _do_check_update():
|
||||
try:
|
||||
async with aiohttp.ClientSession() 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('New version available:', data['name'])
|
||||
print(data['body'])
|
||||
print('Download:', data['html_url'])
|
||||
except aiohttp.ClientConnectionError:
|
||||
print('Failed to check update: connection failed')
|
||||
|
||||
282
views/chat.py
@@ -1,282 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import enum
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import *
|
||||
|
||||
import aiohttp
|
||||
import tornado.websocket
|
||||
|
||||
import blivedm.blivedm as blivedm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(enum.IntEnum):
|
||||
HEARTBEAT = 0
|
||||
JOIN_ROOM = 1
|
||||
ADD_TEXT = 2
|
||||
ADD_GIFT = 3
|
||||
ADD_MEMBER = 4
|
||||
ADD_SUPER_CHAT = 5
|
||||
DEL_SUPER_CHAT = 6
|
||||
|
||||
|
||||
DEFAULT_AVATAR_URL = 'https://static.hdslb.com/images/member/noface.gif'
|
||||
|
||||
_http_session = aiohttp.ClientSession()
|
||||
_avatar_url_cache: Dict[int, str] = {}
|
||||
_last_fetch_avatar_time = datetime.datetime.now()
|
||||
_last_avatar_failed_time = None
|
||||
_uids_to_fetch_avatar = asyncio.Queue(15)
|
||||
|
||||
|
||||
async def get_avatar_url(user_id):
|
||||
if user_id in _avatar_url_cache:
|
||||
return _avatar_url_cache[user_id]
|
||||
|
||||
global _last_avatar_failed_time, _last_fetch_avatar_time
|
||||
cur_time = datetime.datetime.now()
|
||||
# 防止获取头像频率太高被ban
|
||||
if (cur_time - _last_fetch_avatar_time).total_seconds() < 0.2:
|
||||
# 由_fetch_avatar_loop过一段时间再获取并缓存
|
||||
try:
|
||||
_uids_to_fetch_avatar.put_nowait(user_id)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
return DEFAULT_AVATAR_URL
|
||||
|
||||
if _last_avatar_failed_time is not None:
|
||||
if (cur_time - _last_avatar_failed_time).total_seconds() < 3 * 60 + 3:
|
||||
# 3分钟以内被ban,解封大约要15分钟
|
||||
return DEFAULT_AVATAR_URL
|
||||
else:
|
||||
_last_avatar_failed_time = None
|
||||
|
||||
_last_fetch_avatar_time = cur_time
|
||||
try:
|
||||
async with _http_session.get('https://api.bilibili.com/x/space/acc/info',
|
||||
params={'mid': user_id}) as r:
|
||||
if r.status != 200: # 可能会被B站ban
|
||||
logger.warning('Failed to fetch avatar: status=%d %s uid=%d', r.status, r.reason, user_id)
|
||||
_last_avatar_failed_time = cur_time
|
||||
return DEFAULT_AVATAR_URL
|
||||
data = await r.json()
|
||||
except aiohttp.ClientConnectionError:
|
||||
return DEFAULT_AVATAR_URL
|
||||
url = data['data']['face']
|
||||
if not url.endswith('noface.gif'):
|
||||
url += '@48w_48h'
|
||||
_avatar_url_cache[user_id] = url
|
||||
|
||||
if len(_avatar_url_cache) > 50000:
|
||||
for _, key in zip(range(100), _avatar_url_cache):
|
||||
del _avatar_url_cache[key]
|
||||
|
||||
return url
|
||||
|
||||
|
||||
async def _fetch_avatar_loop():
|
||||
while True:
|
||||
try:
|
||||
user_id = await _uids_to_fetch_avatar.get()
|
||||
if user_id in _avatar_url_cache:
|
||||
continue
|
||||
# 延时长一些使实时弹幕有机会获取头像
|
||||
await asyncio.sleep(0.4 - (datetime.datetime.now() - _last_fetch_avatar_time).total_seconds())
|
||||
asyncio.ensure_future(get_avatar_url(user_id))
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
asyncio.ensure_future(_fetch_avatar_loop())
|
||||
|
||||
|
||||
class Room(blivedm.BLiveClient):
|
||||
def __init__(self, room_id):
|
||||
super().__init__(room_id, session=_http_session, heartbeat_interval=10)
|
||||
self.clients: List['ChatHandler'] = []
|
||||
|
||||
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:
|
||||
client.write_message(body)
|
||||
|
||||
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
|
||||
self.send_message(Command.ADD_TEXT, {
|
||||
'avatarUrl': await get_avatar_url(danmaku.uid),
|
||||
'timestamp': danmaku.timestamp,
|
||||
'authorName': danmaku.uname,
|
||||
'authorType': author_type,
|
||||
'content': danmaku.msg,
|
||||
'privilegeType': danmaku.privilege_type,
|
||||
'isGiftDanmaku': bool(danmaku.msg_type),
|
||||
'authorLevel': danmaku.user_level,
|
||||
'isNewbie': danmaku.urank < 10000,
|
||||
'isMobileVerified': bool(danmaku.mobile_verify),
|
||||
'medalLevel': 0 if danmaku.room_id != self.room_id else danmaku.medal_level
|
||||
})
|
||||
|
||||
async def _on_receive_gift(self, gift: blivedm.GiftMessage):
|
||||
if gift.coin_type != 'gold': # 丢人
|
||||
return
|
||||
self.send_message(Command.ADD_GIFT, {
|
||||
'avatarUrl': gift.face,
|
||||
'timestamp': gift.timestamp,
|
||||
'authorName': gift.uname,
|
||||
'giftName': gift.gift_name,
|
||||
'giftNum': gift.num,
|
||||
'totalCoin': gift.total_coin
|
||||
})
|
||||
|
||||
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):
|
||||
self.send_message(Command.ADD_MEMBER, {
|
||||
'avatarUrl': await get_avatar_url(message.uid),
|
||||
'timestamp': message.start_time,
|
||||
'authorName': message.username
|
||||
})
|
||||
|
||||
async def _on_super_chat(self, message: blivedm.SuperChatMessage):
|
||||
self.send_message(Command.ADD_SUPER_CHAT, {
|
||||
'avatarUrl': message.face,
|
||||
'timestamp': message.start_time,
|
||||
'authorName': message.uname,
|
||||
'price': message.price,
|
||||
'content': message.message,
|
||||
'id': message.id
|
||||
})
|
||||
|
||||
async def _on_super_chat_delete(self, message: blivedm.SuperChatDeleteMessage):
|
||||
self.send_message(Command.ADD_SUPER_CHAT, {
|
||||
'ids': message.ids
|
||||
})
|
||||
|
||||
|
||||
class RoomManager:
|
||||
def __init__(self):
|
||||
self._rooms: Dict[int, Room] = {}
|
||||
|
||||
def add_client(self, room_id, client: 'ChatHandler'):
|
||||
if room_id in self._rooms:
|
||||
room = self._rooms[room_id]
|
||||
else:
|
||||
logger.info('Creating room %d', room_id)
|
||||
room = Room(room_id)
|
||||
self._rooms[room_id] = room
|
||||
room.start()
|
||||
room.clients.append(client)
|
||||
|
||||
if client.application.settings['debug']:
|
||||
self.__send_test_message(room)
|
||||
|
||||
def del_client(self, room_id, client: 'ChatHandler'):
|
||||
if room_id not in self._rooms:
|
||||
return
|
||||
room = self._rooms[room_id]
|
||||
room.clients.remove(client)
|
||||
if not room.clients:
|
||||
logger.info('Removing room %d', room_id)
|
||||
room.stop_and_close()
|
||||
del self._rooms[room_id]
|
||||
|
||||
# 测试用
|
||||
@staticmethod
|
||||
def __send_test_message(room):
|
||||
base_data = {
|
||||
'avatarUrl': 'https://i0.hdslb.com/bfs/face/29b6be8aa611e70a3d3ac219cdaf5e72b604f2de.jpg@48w_48h',
|
||||
'timestamp': time.time(),
|
||||
'authorName': 'xfgryujk',
|
||||
}
|
||||
text_data = {
|
||||
**base_data,
|
||||
'authorType': 0,
|
||||
'content': '我能吞下玻璃而不伤身体',
|
||||
'privilegeType': 0,
|
||||
'isGiftDanmaku': False,
|
||||
'authorLevel': 20,
|
||||
'isNewbie': False,
|
||||
'isMobileVerified': True
|
||||
}
|
||||
vip_data = base_data
|
||||
gift_data = {
|
||||
**base_data,
|
||||
'giftName': '礼花',
|
||||
'giftNum': 1,
|
||||
'totalCoin': 28000
|
||||
}
|
||||
room.send_message(Command.ADD_TEXT, text_data)
|
||||
text_data['authorName'] = '主播'
|
||||
text_data['authorType'] = 3
|
||||
text_data['content'] = "I can eat glass, it doesn't hurt me."
|
||||
room.send_message(Command.ADD_TEXT, text_data)
|
||||
room.send_message(Command.ADD_MEMBER, vip_data)
|
||||
room.send_message(Command.ADD_GIFT, gift_data)
|
||||
gift_data['giftName'] = '节奏风暴'
|
||||
gift_data['totalCoin'] = 100000
|
||||
room.send_message(Command.ADD_GIFT, gift_data)
|
||||
gift_data['giftName'] = '摩天大楼'
|
||||
gift_data['totalCoin'] = 450000
|
||||
room.send_message(Command.ADD_GIFT, gift_data)
|
||||
gift_data['giftName'] = '小电视飞船'
|
||||
gift_data['totalCoin'] = 1245000
|
||||
room.send_message(Command.ADD_GIFT, gift_data)
|
||||
|
||||
|
||||
room_manager = RoomManager()
|
||||
|
||||
|
||||
# noinspection PyAbstractClass
|
||||
class ChatHandler(tornado.websocket.WebSocketHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.room_id = None
|
||||
|
||||
def open(self):
|
||||
logger.info('Websocket connected %s', self.request.remote_ip)
|
||||
|
||||
def on_message(self, message):
|
||||
if self.room_id is not None:
|
||||
return
|
||||
body = json.loads(message)
|
||||
if body['cmd'] == Command.JOIN_ROOM:
|
||||
self.room_id = int(body['data']['roomId'])
|
||||
logger.info('Client %s is joining room %d', self.request.remote_ip, self.room_id)
|
||||
room_manager.add_client(self.room_id, self)
|
||||
else:
|
||||
logger.warning('Unknown cmd: %s data: %s', body['cmd'], body['data'])
|
||||
|
||||
def on_close(self):
|
||||
logger.info('Websocket disconnected %s room: %s', self.request.remote_ip, self.room_id)
|
||||
if self.room_id is not None:
|
||||
room_manager.del_client(self.room_id, self)
|
||||
|
||||
# 跨域测试用
|
||||
def check_origin(self, origin):
|
||||
if self.application.settings['debug']:
|
||||
return True
|
||||
return super().check_origin(origin)
|
||||
@@ -1,50 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import uuid
|
||||
|
||||
import views.base
|
||||
from typing import *
|
||||
|
||||
configs: Dict[str, dict] = {}
|
||||
|
||||
ALLOWED_FIELDS = (
|
||||
'showDanmaku', 'showGift', 'mergeSimilarDanmaku', 'minGiftPrice', 'maxSpeed',
|
||||
'maxNumber', 'blockGiftDanmaku', 'blockLevel', 'blockNewbie', 'blockNotMobileVerified',
|
||||
'blockKeywords', 'blockUsers', 'blockMedalLevel', 'css'
|
||||
)
|
||||
|
||||
|
||||
# noinspection PyAbstractClass
|
||||
class ConfigsHandler(views.base.ApiHandler):
|
||||
async def post(self):
|
||||
config_id = str(uuid.uuid4())
|
||||
config = {
|
||||
name: self.json_args[name] for name in ALLOWED_FIELDS
|
||||
}
|
||||
config['id'] = config_id
|
||||
configs[config_id] = config
|
||||
self.set_status(201)
|
||||
self.write(config)
|
||||
|
||||
if len(configs) > 10000:
|
||||
for _, key in zip(range(100), configs):
|
||||
del configs[key]
|
||||
|
||||
|
||||
# noinspection PyAbstractClass
|
||||
class ConfigHandler(views.base.ApiHandler):
|
||||
async def put(self, config_id):
|
||||
config = configs.get(config_id, None)
|
||||
if config is None:
|
||||
self.set_status(404)
|
||||
return
|
||||
for name in ALLOWED_FIELDS:
|
||||
config[name] = self.json_args[name]
|
||||
self.write(config)
|
||||
|
||||
async def get(self, config_id):
|
||||
config = configs.get(config_id, None)
|
||||
if config is None:
|
||||
self.set_status(404)
|
||||
return
|
||||
self.write(config)
|
||||
@@ -1,10 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import tornado.web
|
||||
|
||||
|
||||
# noinspection PyAbstractClass
|
||||
class MainHandler(tornado.web.StaticFileHandler):
|
||||
"""为了使用Vue Router的history模式,把所有请求转发到index.html"""
|
||||
async def get(self, *args, **kwargs):
|
||||
await super().get('index.html', *args, **kwargs)
|
||||