Compare commits

...

83 Commits

Author SHA1 Message Date
John Smith
63644f5499 修复qtv、qtk反了 2020-03-21 19:13:06 +08:00
John Smith
8a57304790 更新版本号v1.4.2 2020-03-21 19:05:53 +08:00
John Smith
d7df071595 修复启动长时间后自动翻译失效的问题 2020-03-21 18:42:27 +08:00
John Smith
6e8b04ef4f 修复删缓存时的错误、提高容错性 2020-03-21 15:23:31 +08:00
John Smith
b73a4e3748 更新版本号v1.4.1 2020-03-01 14:22:12 +08:00
John Smith
102746d0b0 添加显示礼物名、只合并同类礼物 2020-03-01 14:14:19 +08:00
John Smith
2d237af6fd 提示用户已加载页面 2020-02-29 19:20:29 +08:00
John Smith
4030ff43cd 减小编译后文件尺寸 2020-02-22 17:21:47 +08:00
John Smith
092cc8d9ab 更新帮助 2020-02-06 20:31:43 +08:00
John Smith
2d7106ae93 更新版本号v1.4.0 2020-02-06 19:53:33 +08:00
John Smith
d5b885aab4 添加前端获取后端配置 2020-02-06 19:51:03 +08:00
John Smith
34f0effb6a 修复翻译导致弹幕无法滚动到底部的问题 2020-02-06 18:31:05 +08:00
John Smith
d3e9300fa4 添加自动翻译前端 2020-02-06 17:39:56 +08:00
John Smith
0c9560f8ca 添加自动翻译后端 2020-02-06 12:48:20 +08:00
John Smith
1c5df01fbc websocket消息优化 2020-02-05 17:28:10 +08:00
John Smith
49cdc56cff 防止队列中出现相同uid时重复获取头像 2020-02-05 12:58:26 +08:00
John Smith
00e1f31f84 优化前端防卡代码 2020-02-04 20:33:29 +08:00
John Smith
b7e06652b4 防止前端弹幕卡住 2020-02-04 20:13:11 +08:00
John Smith
0cf49ee042 防止更新版本后浏览器加载缓存 2020-02-04 16:26:28 +08:00
John Smith
7d610ecfdc 修复获取头像future没有设置结果的问题、部分头像URL没有处理的问题 2020-02-04 16:12:16 +08:00
John Smith
c8e4278c39 修复被B站ban后不会恢复获取头像的问题 2020-02-03 19:29:29 +08:00
John Smith
07b47a26ca 更新版本号v1.3.0
close #15
2020-02-03 16:23:55 +08:00
John Smith
8d55331e6c 头像缓存持久化 2020-02-03 16:18:21 +08:00
John Smith
cae06858fc 前端配置放在URL参数 2020-02-01 21:27:50 +08:00
John Smith
8d4e8e6f35 一些前端优化 2020-02-01 18:17:23 +08:00
John Smith
8d40f9f9e5 更新版本号 2020-01-12 23:02:15 +08:00
John Smith
e93f6b2383 兼容B站弹幕升级 2020-01-12 22:46:02 +08:00
John Smith
4c2e216191 添加帮助 2019-12-16 23:40:01 +08:00
John Smith
2a35541dc5 捕获write_message异常 2019-12-08 17:55:58 +08:00
John Smith
a927282e77 修复弹幕太快时消息平滑失效的问题 2019-12-01 16:36:50 +08:00
John Smith
a7faac5425 修复有时消息不能正确合并的问题 2019-11-30 23:57:52 +08:00
John Smith
a22496c355 修复固定消息的顺序 2019-11-30 23:24:06 +08:00
John Smith
20ec58b965 Merge pull request #13 from simon300000/https-face
auto http/https avatarUrl
2019-11-30 14:26:20 +08:00
simon3000
7453d7e890 (python) http/https avatarUrl 2019-11-30 07:20:35 +01:00
simon3000
1991e33b9d Revert "auto http/https avatarUrl"
This reverts commit 4b98c56965.
2019-11-30 07:16:38 +01:00
John Smith
f429a6a03d Merge pull request #12 from simon300000/generate-https
roomUrl auto https support
2019-11-30 14:16:28 +08:00
simon3000
4b98c56965 auto http/https avatarUrl 2019-11-30 07:08:17 +01:00
simon3000
eb43a37ae5 roomUrl auto https support 2019-11-30 06:56:05 +01:00
John Smith
1cf6d88460 各种优化 2019-11-29 23:48:43 +08:00
John Smith
9ddbebbd7e 更新版本号 2019-11-28 22:25:50 +08:00
John Smith
ca7f27d536 添加平滑滚动弹幕 2019-11-28 22:18:27 +08:00
John Smith
98184e6732 添加平滑消息队列 2019-11-28 00:52:16 +08:00
John Smith
3b2616c278 封装addMessage到ChatRenderer
暂时移除了messagesBuffer
2019-11-24 18:48:10 +08:00
John Smith
9565c52e23 更新版本号 2019-10-13 21:19:33 +08:00
John Smith
bb1acaecd6 配置允许储存任意JSON 2019-10-13 21:04:03 +08:00
John Smith
dcbb90a7bd 兼容B站弹幕升级 2019-10-13 20:27:52 +08:00
John Smith
c46542bee0 兼容HTTPS 2019-10-13 17:02:54 +08:00
John Smith
709ec0585e 更新README、版本号 2019-10-06 16:56:40 +08:00
John Smith
a9c6709080 添加BiliSC链接 2019-10-06 16:41:47 +08:00
John Smith
938f758920 添加掉线重连 2019-10-06 16:20:48 +08:00
John Smith
ce4974be94 添加删除SC功能 2019-10-06 12:23:47 +08:00
John Smith
040bad0eba 修复空SC下面不是圆角边框的问题 2019-10-06 10:55:01 +08:00
John Smith
60e6b5ec99 隐藏空SC内容、更新版本号 2019-09-23 22:28:11 +08:00
John Smith
695a111c9a SC使用官方功能 2019-09-23 22:22:27 +08:00
John Smith
0160549dbe 重复标记用HSL、代码优化 2019-09-22 17:18:34 +08:00
John Smith
3fd0d9ab28 更新JS包 2019-09-17 23:53:26 +08:00
John Smith
b1061ae103 更新blivedm 2019-09-17 23:08:03 +08:00
John Smith
6e737ce33f 样式生成器添加梗 2019-09-16 22:21:44 +08:00
John Smith
10db6b9ae2 添加最大弹幕数 2019-09-16 22:15:32 +08:00
John Smith
1514bfed5b 自动检查更新 2019-09-16 20:48:03 +08:00
John Smith
dc264d40f0 SC配置按美元汇率 2019-09-15 23:31:23 +08:00
John Smith
104e647f61 样式生成器添加默认字体 2019-07-18 09:15:27 +08:00
John Smith
d5d7fdde6d 日志改用英语 2019-07-18 09:08:03 +08:00
John Smith
23aa88d564 时间补齐2位 2019-07-18 08:31:58 +08:00
John Smith
ac1bda0392 修改一堆文案 2019-07-15 21:30:52 +08:00
John Smith
5cdb786966 去掉了点击预览会消失的CSS 2019-07-15 19:50:51 +08:00
John Smith
2b5ca2e219 Merge pull request #8 from simon300000/honest
「尝试」优化了样式生成器的CSS,加入了一个只有好孩子才能看到的小按钮
2019-07-15 19:30:39 +08:00
John Smith
fc77b246c2 我傻了 2019-07-15 18:01:38 +08:00
John Smith
083832cb3e Merge pull request #7 from simon300000/patch-css
增加element字体CSS
2019-07-15 17:58:42 +08:00
John Smith
804db2fec1 全局应用字体 2019-07-15 17:56:03 +08:00
simon3000
769964d5db 增加element字体CSS 2019-07-15 17:56:03 +08:00
John Smith
aba0efca34 Merge pull request #9 from xfgryujk/dependabot/npm_and_yarn/frontend/lodash-4.17.13
Bump lodash from 4.17.11 to 4.17.13 in /frontend
2019-07-15 17:35:06 +08:00
dependabot[bot]
9ff18fe8b5 Bump lodash from 4.17.11 to 4.17.13 in /frontend
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.11 to 4.17.13.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.11...4.17.13)

Signed-off-by: dependabot[bot] <support@github.com>
2019-07-15 09:33:24 +00:00
simon3000
ee78cbf90e 改用refs直接执行function 2019-07-13 01:51:58 +02:00
simon3000
ffc3c9d8a0 将监听的event名称改为exampleResize 2019-07-13 01:16:57 +02:00
simon3000
a69b6b6406 给ChatRenderer加上demo prop, 为true时自动resize 2019-07-13 01:14:55 +02:00
simon3000
1252402e5e Merge branch 'patch-scroll-on-resize' into honest 2019-07-13 01:03:45 +02:00
simon3000
53be587189 自动resize 2019-07-11 23:14:11 +02:00
simon3000
d3cc8b0c77 👿 2019-07-11 22:52:06 +02:00
simon3000
4f4d117ce0 使用calc优化CSS生成器的浏览部分 2019-07-11 22:51:41 +02:00
simon3000
0d1e38346e 增加element字体CSS 2019-07-11 22:15:46 +02:00
simon3000
273dc0f43f 窗口缩放时滚动 2019-07-11 21:50:28 +02:00
John Smith
26f19b6630 Update README.md 2019-07-11 11:35:17 +08:00
48 changed files with 13087 additions and 5920 deletions

1
.gitignore vendored
View File

@@ -105,3 +105,4 @@ venv.bak/
.idea/
data/database.db

View File

@@ -1,29 +1,36 @@
# 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)~~
![OBS截图](https://github.com/xfgryujk/blivechat/blob/master/screenshots/obs.png)
![Chrome截图](https://github.com/xfgryujk/blivechat/blob/master/screenshots/chrome.png)
![样式生成器截图](https://github.com/xfgryujk/blivechat/blob/master/screenshots/stylegen.png)
## 特性
* 兼容YouTube直播chat的样式
* 金瓜子礼物模仿Super Chat显示
* 兼容YouTube直播评论栏的样式
* 金瓜子礼物模仿醒目留言显示
* 高亮舰队、房管、主播的用户名
* 支持屏蔽弹幕、限制最大速度等设置
* 支持屏蔽弹幕、合并相似弹幕等设置
* 自带样式生成器
* 支持自动翻译弹幕、醒目留言到日语
## 使用方法
### 发布版
### 本地使用
1. 下载[发布版](https://github.com/xfgryujk/blivechat/releases)仅提供x64 Windows版
2. 双击`blivechat.exe`运行服务器或者用命令行可以指定host和端口号
```bat
blivechat.exe --host 127.0.0.1 --port 12450
```
3. 用浏览器打开[http://localhost](http://localhost)输入房间ID保存配置进入房间,复制房间URL
4. (可选)用样式生成器生成样式复制CSS
5. 在OBS中添加浏览器源输入URL和自定义CSS或者可以在首页的样式设置里输入CSS
3. 用浏览器打开[http://localhost:12450](http://localhost:12450)输入房间ID保存配置复制房间URL
4. 用样式生成器生成样式复制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](http://localhost)输入房间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)以下略

View File

@@ -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

474
api/chat.py Normal file
View File

@@ -0,0 +1,474 @@
# -*- 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:
room_manager.del_client(self.room_id, client)
def send_message_if(self, can_send_func: Callable[['ChatHandler'], bool], cmd, data):
body = json.dumps({'cmd': cmd, 'data': data})
for client in filter(can_send_func, self.clients):
try:
client.write_message(body)
except tornado.websocket.WebSocketClosedError:
room_manager.del_client(self.room_id, client)
async def _on_receive_danmaku(self, danmaku: blivedm.DanmakuMessage):
asyncio.ensure_future(self.__on_receive_danmaku(danmaku))
async def __on_receive_danmaku(self, danmaku: blivedm.DanmakuMessage):
if danmaku.uid == self.room_owner_uid:
author_type = 3 # 主播
elif danmaku.admin:
author_type = 2 # 房管
elif danmaku.privilege_type != 0: # 1总督2提督3舰长
author_type = 1 # 舰队
else:
author_type = 0
need_translate = self._need_translate(danmaku.msg)
if need_translate:
translation = models.translate.get_translation_from_cache(danmaku.msg)
if translation is None:
# 没有缓存,需要后面异步翻译后通知
translation = ''
else:
need_translate = False
else:
translation = ''
id_ = uuid.uuid4().hex
# 为了节省带宽用list而不是dict
self.send_message(Command.ADD_TEXT, [
# 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.get(room_id, None)
if room is None:
return
room.clients.append(client)
logger.info('%d clients in room %s', len(room.clients), room_id)
if client.auto_translate:
room.auto_translate_count += 1
if client.application.settings['debug']:
await client.send_test_message()
def del_client(self, room_id, client: 'ChatHandler'):
room = self._rooms.get(room_id, None)
if room is None:
return
try:
room.clients.remove(client)
except ValueError:
# _add_room未完成没有执行到room.clients.append
pass
else:
logger.info('%d clients in room %s', len(room.clients), room_id)
if client.auto_translate:
room.auto_translate_count = max(0, room.auto_translate_count - 1)
if not room.clients:
self._del_room(room_id)
async def _add_room(self, room_id):
if room_id in self._rooms:
return True
logger.info('Creating room %d', room_id)
self._rooms[room_id] = room = Room(room_id)
if await room.init_room():
room.start()
logger.info('%d rooms', len(self._rooms))
return True
else:
self._del_room(room_id)
return False
def _del_room(self, room_id):
room = self._rooms.get(room_id, None)
if room is None:
return
logger.info('Removing room %d', room_id)
for client in room.clients:
client.close()
room.stop_and_close()
self._rooms.pop(room_id, None)
logger.info('%d rooms', len(self._rooms))
# noinspection PyAbstractClass
class ChatHandler(tornado.websocket.WebSocketHandler):
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:
self.on_close()

25
api/main.py Normal file
View 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
}
})

Submodule blivedm updated: 9eb953720c...15669a2084

45
config.py Normal file
View 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
View 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

View File

@@ -1,5 +1,13 @@
module.exports = {
presets: [
'@vue/app'
],
plugins: [
[
'component', {
'libraryName': 'element-ui',
'styleLibraryName': 'theme-chalk'
}
]
]
}

15929
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
"core-js": "^2.6.5",
"downloadjs": "^1.4.7",
"element-ui": "^2.9.1",
"lodash": "^4.17.11",
"lodash": "^4.17.13",
"vue": "^2.6.10",
"vue-i18n": "^8.11.2",
"vue-router": "^3.0.6"
@@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -1,14 +1,13 @@
import axios from 'axios'
import {mergeConfig} from '@/utils'
import stylegen from '@/views/StyleGenerator/stylegen'
export const DEFAULT_CONFIG = {
minGiftPrice: 6.911, // $1
mergeSimilarDanmaku: true,
minGiftPrice: 7, // $1
showDanmaku: true,
showGift: true,
maxSpeed: 4,
showGiftName: false,
mergeSimilarDanmaku: true,
mergeGift: true,
maxNumber: 60,
blockGiftDanmaku: true,
blockLevel: 0,
@@ -18,7 +17,7 @@ export const DEFAULT_CONFIG = {
blockUsers: '',
blockMedalLevel: 0,
css: stylegen.getStyle(stylegen.DEFAULT_CONFIG)
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
}

View File

@@ -10,7 +10,7 @@
<div id="event-text" class="style-scope yt-live-chat-legacy-paid-message-renderer">{{title}}</div>
<div id="detail-text" class="style-scope yt-live-chat-legacy-paid-message-renderer">{{content}}</div>
</div>
<div id="timestamp" class="style-scope yt-live-chat-legacy-paid-message-renderer">{{time}}</div>
<div id="timestamp" class="style-scope yt-live-chat-legacy-paid-message-renderer">{{timeText}}</div>
</div>
</div>
<div id="inline-action-button-container" class="style-scope yt-live-chat-legacy-paid-message-renderer" aria-hidden="true">
@@ -21,6 +21,7 @@
<script>
import ImgShadow from './ImgShadow.vue'
import * as utils from '@/utils'
export default {
name: 'LegacyPaidMessage',
@@ -32,7 +33,12 @@ export default {
authorName: String,
title: String,
content: String,
time: String
time: Date
},
computed: {
timeText() {
return utils.getTimeTextMinSec(this.time)
}
}
}
</script>

View File

@@ -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,
@@ -16,9 +17,9 @@
<div id="header-content" class="style-scope yt-live-chat-paid-message-renderer">
<div id="header-content-primary-column" class="style-scope yt-live-chat-paid-message-renderer">
<div id="author-name" class="style-scope yt-live-chat-paid-message-renderer">{{authorName}}</div>
<div id="purchase-amount" class="style-scope yt-live-chat-paid-message-renderer">CN¥{{price}}</div>
<div id="purchase-amount" class="style-scope yt-live-chat-paid-message-renderer">{{priceText}}</div>
</div>
<span id="timestamp" class="style-scope yt-live-chat-paid-message-renderer">{{time}}</span>
<span id="timestamp" class="style-scope yt-live-chat-paid-message-renderer">{{timeText}}</span>
</div>
</div>
<div id="content" class="style-scope yt-live-chat-paid-message-renderer">
@@ -33,6 +34,7 @@
<script>
import ImgShadow from './ImgShadow.vue'
import * as constants from './constants'
import * as utils from '@/utils'
export default {
name: 'PaidMessage',
@@ -43,12 +45,18 @@ export default {
avatarUrl: String,
authorName: String,
price: Number, // 价格,人民币
time: String,
time: Date,
content: String
},
computed: {
color() {
return constants.getPriceConfig(this.price).colors
},
priceText() {
return 'CN¥' + utils.formatCurrency(this.price)
},
timeText() {
return utils.getTimeTextMinSec(this.time)
}
}
}

View File

@@ -4,7 +4,7 @@
:imgUrl="avatarUrl"
></img-shadow>
<div id="content" class="style-scope yt-live-chat-text-message-renderer">
<span id="timestamp" class="style-scope yt-live-chat-text-message-renderer">{{time}}</span>
<span id="timestamp" class="style-scope yt-live-chat-text-message-renderer">{{timeText}}</span>
<yt-live-chat-author-chip class="style-scope yt-live-chat-text-message-renderer">
<span id="author-name" dir="auto" class="style-scope yt-live-chat-author-chip" :type="authorTypeText">
{{authorName}}
@@ -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,9 +31,11 @@
import ImgShadow from './ImgShadow.vue'
import AuthorBadge from './AuthorBadge.vue'
import * as constants from './constants'
import * as utils from '@/utils'
const REPEATED_MARK_COLOR_START = [64, 158, 255]
const REPEATED_MARK_COLOR_END = [245, 108, 108]
// HSL
const REPEATED_MARK_COLOR_START = [210, 100.0, 62.5]
const REPEATED_MARK_COLOR_END = [360, 87.3, 69.2]
export default {
name: 'TextMessage',
@@ -41,7 +45,7 @@ export default {
},
props: {
avatarUrl: String,
time: String,
time: Date,
authorName: String,
authorType: Number,
content: String,
@@ -49,6 +53,9 @@ export default {
repeated: Number
},
computed: {
timeText() {
return utils.getTimeTextMinSec(this.time)
},
authorTypeText() {
return constants.AUTHOR_TYPE_TO_TEXT[this.authorType]
},
@@ -65,18 +72,18 @@ export default {
color[i] = REPEATED_MARK_COLOR_START[i] + (REPEATED_MARK_COLOR_END[i] - REPEATED_MARK_COLOR_START[i]) * t
}
}
return `rgb(${color.join(', ')})`
return `hsl(${color[0]}, ${color[1]}%, ${color[2]}%)`
}
}
}
</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;

View File

@@ -34,13 +34,15 @@
<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'
@@ -54,7 +56,11 @@ export default {
PaidMessage
},
props: {
messages: Array
messages: Array,
showGiftName: {
type: Boolean,
default: config.DEFAULT_CONFIG.showGiftName
}
},
data() {
return {
@@ -64,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)
},
@@ -106,9 +124,9 @@ export default {
},
getText(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return '会员'
return 'Member'
}
return `CN¥${message.price}`
return 'CN¥' + formatCurrency(message.price)
},
updateProgress() {
let curTime = new Date()

View File

@@ -18,12 +18,17 @@ export const GUARD_LEVEL_TO_TEXT = [
]
export const MESSAGE_TYPE_TEXT = 0
export const MESSAGE_TYPE_MEMBER = 1
export const MESSAGE_TYPE_GIFT = 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
export const PRICE_CONFIGS = [
{ // $100红
price: 1245, // >= 1245小电视飞船
price: 100 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(230,33,23,1)',
headerBg: 'rgba(208,0,0,1)',
@@ -35,7 +40,7 @@ export const PRICE_CONFIGS = [
pinTime: 60
},
{ // $50品红
price: 450, // 450~1245摩天大楼
price: 50 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(233,30,99,1)',
headerBg: 'rgba(194,24,91,1)',
@@ -47,7 +52,7 @@ export const PRICE_CONFIGS = [
pinTime: 30
},
{ // $20橙
price: 100, // 100~450节奏风暴、天空之翼
price: 20 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(245,124,0,1)',
headerBg: 'rgba(230,81,0,1)',
@@ -59,7 +64,7 @@ export const PRICE_CONFIGS = [
pinTime: 10
},
{ // $10黄
price: 52, // 52~100疯狂打call
price: 10 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(255,202,40,1)',
headerBg: 'rgba(255,179,0,1)',
@@ -71,7 +76,7 @@ export const PRICE_CONFIGS = [
pinTime: 5
},
{ // $5绿
price: 28, // 28~52礼花
price: 5 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(29,233,182,1)',
headerBg: 'rgba(0,191,165,1)',
@@ -83,7 +88,7 @@ export const PRICE_CONFIGS = [
pinTime: 2
},
{ // $2浅蓝
price: 9.9, // 9.9~28B坷垃
price: 2 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(0,229,255,1)',
headerBg: 'rgba(0,184,212,1)',
@@ -95,7 +100,7 @@ export const PRICE_CONFIGS = [
pinTime: 0
},
{ // $1蓝
price: 0, // 0~9.9,丢人
price: 1 * EXCHANGE_RATE,
colors: {
contentBg: 'rgba(30,136,229,1)',
headerBg: 'rgba(21,101,192,1)',
@@ -116,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}`
}

View File

@@ -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()
}
}
}
}

View File

@@ -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,31 +14,31 @@ 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',
giftDanmaku: 'System messages (gift effect)',
blockLevel: 'User level lower than',
informalUser: 'Informal users',
unverifiedUser: 'Unverified users',
giftDanmaku: 'Block system messages (gift effect)',
blockLevel: 'Block user level lower than',
informalUser: 'Block informal users',
unverifiedUser: 'Block unverified users',
blockKeywords: 'Block keywords',
onePerLine: 'One per line',
blockUsers: 'Block users',
blockMedalLevel: 'Medal level lower than',
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: {
@@ -105,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'
}
}

View File

@@ -1,8 +1,10 @@
export default {
export default {
sidebar: {
home: 'トップページ',
stylegen: 'スタイルジェネレータ',
projectAddress: 'プロジェクトアドレス'
help: 'ヘルプ',
projectAddress: 'プロジェクトアドレス',
giftRecord: 'スーパーチャット記録'
},
home: {
roomIdEmpty: 'ルームのIDを空白にすることはできません',
@@ -12,31 +14,31 @@
roomId: 'ルームID',
showDanmaku: 'コメントを表示する',
showGift: 'スーパーチャットと新メンバーを表示する',
showGiftName: 'ギフト名を表示する',
mergeSimilarDanmaku: '同じコメントを合併する',
mergeGift: 'ギフトを合併する',
minGiftPrice: '最低表示スーパーチャット価格CNY',
maxSpeed: 'コメントの最大速度(バー/秒、0は無制限',
maxNumber: '最大コメント',
block: 'ブロック',
giftDanmaku: 'システムコメント(プレゼント効果)',
blockLevel: 'ユーザーレベルがx未満',
informalUser: '非公式ユーザー',
unverifiedUser: '認証されていないユーザー',
blockKeywords: 'キーワード',
giftDanmaku: 'ブロックシステムコメント(プレゼント効果)',
blockLevel: 'ブロックユーザーレベルがx未満',
informalUser: 'ブロック非公式ユーザー',
unverifiedUser: 'ブロック認証されていないユーザー',
blockKeywords: 'ブロックキーワード',
onePerLine: '1行に1つずつ',
blockUsers: 'ユーザー',
blockMedalLevel: '勲章等級がx未満',
blockUsers: 'ブロックユーザー',
blockMedalLevel: 'ブロック勲章等級がx未満',
style: 'スタイル',
advanced: 'アドバンスド',
autoTranslate: '自動翻訳コメントから日本語へ',
roomUrl: 'ルームのURL',
copy: 'コピー',
saveConfig: 'コンフィグを保存する',
enterRoom: 'ルームに入る',
exportConfig: 'コンフィグの導出',
importConfig: 'コンフィグの導入',
failedToSave: '保存に失敗しました:',
successfullySaved: '保存に成功しました',
failedToParseConfig: 'コンフィグ解析に失敗しました'
},
stylegen: {
@@ -105,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を入力する'
}
}

View File

@@ -2,7 +2,9 @@ export default {
sidebar: {
home: '首页',
stylegen: '样式生成器',
projectAddress: '项目地址'
help: '帮助',
projectAddress: '项目地址',
giftRecord: '打赏记录'
},
home: {
roomIdEmpty: '房间ID不能为空',
@@ -11,32 +13,32 @@ export default {
general: '常规',
roomId: '房间ID',
showDanmaku: '显示弹幕',
showGift: '显示礼物和新舰长',
showGift: '显示打赏和新舰长',
showGiftName: '显示礼物名',
mergeSimilarDanmaku: '合并相似弹幕',
minGiftPrice: '最低显示礼物价格(元)',
maxSpeed: '弹幕最大速度(条/秒0为无限制',
mergeGift: '合并礼物',
minGiftPrice: '最低显示打赏价格(元',
maxNumber: '最大弹幕数',
block: '屏蔽',
giftDanmaku: '礼物弹幕',
blockLevel: '用户等级低于',
informalUser: '非正式会员',
unverifiedUser: '未绑定手机用户',
giftDanmaku: '屏蔽礼物弹幕',
blockLevel: '屏蔽用户等级低于',
informalUser: '屏蔽非正式会员',
unverifiedUser: '屏蔽未绑定手机用户',
blockKeywords: '屏蔽关键词',
onePerLine: '一行一个',
blockUsers: '屏蔽用户',
blockMedalLevel: '当前直播间勋章等级低于',
blockMedalLevel: '屏蔽当前直播间勋章等级低于',
style: '样式',
advanced: '高级',
autoTranslate: '自动翻译弹幕到日语',
roomUrl: '房间URL',
copy: '复制',
saveConfig: '保存配置',
enterRoom: '进入房间',
exportConfig: '导出配置',
importConfig: '导入配置',
failedToSave: '保存失败:',
successfullySaved: '保存成功',
failedToParseConfig: '配置解析失败:'
},
stylegen: {
@@ -75,7 +77,7 @@ export default {
moderatorMessageBgColor: '房管消息背景色',
memberMessageBgColor: '舰长消息背景色',
scAndNewMember: '礼物、舰长',
scAndNewMember: '打赏、舰长',
firstLineFont: '第一行字体',
firstLineFontSize: '第一行字体尺寸',
firstLineLineHeight: '第一行行高0为默认',
@@ -89,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: '进入动画',
@@ -105,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'
}
}

View File

@@ -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

View File

@@ -8,6 +8,9 @@
<h1 class="sidebar-title">blivechat</h1>
</router-link>
</div>
<div class="version">
v1.4.2
</div>
<sidebar></sidebar>
</el-aside>
<el-main>
@@ -47,6 +50,10 @@ export default {
</script>
<style>
html {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
}
html, body, #app, .app-wrapper, .sidebar-container {
height: 100%;
}
@@ -114,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;
}

View File

@@ -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},

View File

@@ -6,6 +6,29 @@ export function mergeConfig (config, defaultConfig) {
return res
}
export default {
mergeConfig
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
}).format(price)
}
export function getTimeTextMinSec (date) {
let min = ('00' + date.getMinutes()).slice(-2)
let sec = ('00' + date.getSeconds()).slice(-2)
return `${min}:${sec}`
}

View 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>

View File

@@ -16,14 +16,20 @@
<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 :label="$t('home.maxNumber')">
<el-input v-model.number="form.maxNumber" type="number" min="1"></el-input>
</el-form-item>
</el-tab-pane>
@@ -51,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>
@@ -73,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')

View File

@@ -1,16 +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_JOIN_ROOM = 0
const COMMAND_ADD_TEXT = 1
const COMMAND_ADD_GIFT = 2
const COMMAND_ADD_MEMBER = 3
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',
@@ -18,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.bind(this)
this.websocket.onmessage = this.onWsMessage.bind(this)
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.getMinutes()}:${time.getSeconds()}`,
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: {
@@ -101,34 +160,73 @@ export default {
break
}
let price = data.totalCoin / 1000
if (price < this.config.minGiftPrice) // 丢人
if (this.mergeSimilarGift(data.authorName, price, data.giftName, data.num)) {
break
}
if (price < this.config.minGiftPrice) { // 丢人
break
}
message = {
id: data.id,
type: constants.MESSAGE_TYPE_GIFT,
avatarUrl: data.avatarUrl,
time: new Date(data.timestamp * 1000),
authorName: data.authorName,
price: price,
time: `${time.getMinutes()}:${time.getSeconds()}`,
content: `Sent ${data.giftName}x${data.giftNum}`
giftName: data.giftName,
num: data.num
}
break
}
case COMMAND_ADD_MEMBER:
if (!this.config.showGift) {
if (!this.config.showGift || !this.filterNewMemberMessage(data)) {
break
}
message = {
id: data.id,
type: constants.MESSAGE_TYPE_MEMBER,
avatarUrl: data.avatarUrl,
time: `${time.getMinutes()}:${time.getSeconds()}`,
time: new Date(data.timestamp * 1000),
authorName: data.authorName,
title: 'NEW MEMBER!',
content: `Welcome ${data.authorName}`
content: `Welcome ${data.authorName}!`
}
break
case COMMAND_ADD_SUPER_CHAT:
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: new Date(data.timestamp * 1000),
content: data.content.trim()
}
break
case COMMAND_DEL_SUPER_CHAT:
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) {
@@ -143,77 +241,35 @@ export default {
} else if (this.config.blockMedalLevel > 0 && data.medalLevel < this.config.blockMedalLevel) {
return false
}
for (let keyword of this.config.blockKeywords) {
return this.filterSuperChatMessage(data)
},
filterSuperChatMessage(data) {
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 > 50) {
this.messages.splice(0, this.messages.length - 50)
}
})
},
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)
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -188,9 +188,9 @@
</el-form>
</el-col>
<el-col :span="12">
<div id="example-container">
<div ref="exampleContainer" id="example-container">
<div id="fakebody">
<chat-renderer :paidMessages="examplePaidMessages" :messages="messages" :css="exampleCss"></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'
@@ -211,19 +211,20 @@ let textMessageTemplate = {
addTime: time,
type: constants.MESSAGE_TYPE_TEXT,
avatarUrl: 'https://static.hdslb.com/images/member/noface.gif',
time: `${time.getMinutes()}:${time.getSeconds()}`,
time: time,
authorName: '',
authorType: constants.AUTHRO_TYPE_NORMAL,
content: '',
privilegeType: 0,
repeated: 1
repeated: 1,
translation: ''
}
let legacyPaidMessageTemplate = {
id: 0,
addTime: time,
type: constants.MESSAGE_TYPE_MEMBER,
avatarUrl: 'https://static.hdslb.com/images/member/noface.gif',
time: `${time.getMinutes()}:${time.getSeconds()}`,
time: time,
authorName: '',
title: 'NEW MEMBER!',
content: ''
@@ -231,66 +232,67 @@ let legacyPaidMessageTemplate = {
let paidMessageTemplate = {
id: 0,
addTime: time,
type: constants.MESSAGE_TYPE_GIFT,
type: constants.MESSAGE_TYPE_SUPER_CHAT,
avatarUrl: 'https://static.hdslb.com/images/member/noface.gif',
authorName: '',
price: 0,
time: `${time.getMinutes()}:${time.getSeconds()}`,
content: ''
time: time,
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: 'Paryi',
content: 'Welcome Paryi'
}, {
id: (nextId++).toString(),
authorName: '进击的冰糖',
content: 'Welcome 进击的冰糖!'
},
{
...paidMessageTemplate,
id: nextId++,
authorName: '石油佬',
price: 1245,
content: 'Sent 小电视飞船x1'
}, {
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++,
authorName: '臭DD',
price: 28,
content: 'Sent 礼花x1'
id: (nextId++).toString(),
authorName: '夏色祭保護協会会長',
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,6 +315,21 @@ 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() {
this.$refs.renderer.addMessages(EXAMPLE_MESSAGES)
let observer = new MutationObserver(() => this.$refs.renderer.scrollToBottom())
observer.observe(this.$refs.exampleContainer, {attributes: true})
},
methods: {
getFontSuggestions(query, callback) {
let res = this.FONTS.map(font => {return {value: font}})
@@ -326,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()
@@ -336,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>
@@ -357,8 +364,8 @@ export default {
#example-container {
position: fixed;
top: 30px;
right: 30px;
width: 400px;
left: calc(210px + 40px + (100vw - 210px - 40px) / 2);
width: calc((100vw - 210px - 40px) / 2 - 40px - 30px);
height: calc(100vh - 110px);
background-color: #444;
@@ -380,6 +387,9 @@ export default {
background-position:0 0, 16px 0, 16px -16px, 0px 16px;
padding: 25px;
resize: both;
overflow: hidden;
}
.app-wrapper.mobile #example-container {

View File

@@ -1,5 +1,5 @@
import {mergeConfig} from '@/utils'
import fonts from './fonts'
import * as fonts from './fonts'
export const DEFAULT_CONFIG = {
showOutlines: true,
@@ -63,6 +63,8 @@ export const DEFAULT_CONFIG = {
reverseSlide: false
}
const FALLBACK_FONTS = ', "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "\\5FAE\\8F6F\\96C5\\9ED1", SimHei, Arial, sans-serif'
export function setLocalConfig (config) {
config = mergeConfig(config, DEFAULT_CONFIG)
window.localStorage.stylegenConfig = JSON.stringify(config)
@@ -105,7 +107,7 @@ yt-live-chat-author-chip #author-name {
/* Outlines */
yt-live-chat-renderer * {
${getShowOutlinesStyle(config)}
font-family: "${config.messageFont}", sans-serif;
font-family: "${config.messageFont}"${FALLBACK_FONTS};
font-size: ${config.messageFontSize}px !important;
line-height: ${config.messageLineHeight}px !important;
}
@@ -116,11 +118,11 @@ yt-live-chat-legacy-paid-message-renderer #content {
}
/* Hide scrollbar. */
yt-live-chat-item-list-renderer #items{
yt-live-chat-item-list-renderer #items {
overflow: hidden !important;
}
yt-live-chat-item-list-renderer #item-scroller{
yt-live-chat-item-list-renderer #item-scroller {
overflow: hidden !important;
}
@@ -164,7 +166,7 @@ yt-live-chat-text-message-renderer #chat-badges {
yt-live-chat-text-message-renderer #timestamp {
display: ${config.showTime ? 'inline' : 'none'} !important;
${config.timeColor ? `color: ${config.timeColor} !important;` : ''}
font-family: "${config.timeFont}", sans-serif;
font-family: "${config.timeFont}"${FALLBACK_FONTS};
font-size: ${config.timeFontSize}px !important;
line-height: ${config.timeLineHeight || config.timeFontSize}px !important;
}
@@ -188,7 +190,7 @@ yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="memb
/* Channel names. */
yt-live-chat-text-message-renderer #author-name {
${config.userNameColor ? `color: ${config.userNameColor} !important;` : ''}
font-family: "${config.userNameFont}", sans-serif;
font-family: "${config.userNameFont}"${FALLBACK_FONTS};
font-size: ${config.userNameFontSize}px !important;
line-height: ${config.userNameLineHeight || config.userNameFontSize}px !important;
}
@@ -199,7 +201,7 @@ ${getShowColonStyle(config)}
yt-live-chat-text-message-renderer #message,
yt-live-chat-text-message-renderer #message * {
${config.messageColor ? `color: ${config.messageColor} !important;` : ''}
font-family: "${config.messageFont}", sans-serif;
font-family: "${config.messageFont}"${FALLBACK_FONTS};
font-size: ${config.messageFontSize}px !important;
line-height: ${config.messageLineHeight || config.messageFontSize}px !important;
}
@@ -214,7 +216,7 @@ yt-live-chat-paid-message-renderer #author-name *,
yt-live-chat-legacy-paid-message-renderer #event-text,
yt-live-chat-legacy-paid-message-renderer #event-text * {
${config.firstLineColor ? `color: ${config.firstLineColor} !important;` : ''}
font-family: "${config.firstLineFont}", sans-serif;
font-family: "${config.firstLineFont}"${FALLBACK_FONTS};
font-size: ${config.firstLineFontSize}px !important;
line-height: ${config.firstLineLineHeight || config.firstLineFontSize}px !important;
}
@@ -224,7 +226,7 @@ yt-live-chat-paid-message-renderer #purchase-amount *,
yt-live-chat-legacy-paid-message-renderer #detail-text,
yt-live-chat-legacy-paid-message-renderer #detail-text * {
${config.secondLineColor ? `color: ${config.secondLineColor} !important;` : ''}
font-family: "${config.secondLineFont}", sans-serif;
font-family: "${config.secondLineFont}"${FALLBACK_FONTS};
font-size: ${config.secondLineFontSize}px !important;
line-height: ${config.secondLineLineHeight || config.secondLineFontSize}px !important;
}
@@ -232,7 +234,7 @@ yt-live-chat-legacy-paid-message-renderer #detail-text * {
yt-live-chat-paid-message-renderer #content,
yt-live-chat-paid-message-renderer #content * {
${config.scContentColor ? `color: ${config.scContentColor} !important;` : ''}
font-family: "${config.scContentFont}", sans-serif;
font-family: "${config.scContentFont}"${FALLBACK_FONTS};
font-size: ${config.scContentFontSize}px !important;
line-height: ${config.scContentLineHeight || config.scContentFontSize}px !important;
}
@@ -273,7 +275,7 @@ yt-live-chat-ticker-paid-message-item-renderer *,
yt-live-chat-ticker-sponsor-item-renderer,
yt-live-chat-ticker-sponsor-item-renderer * {
${config.secondLineColor ? `color: ${config.secondLineColor} !important;` : ''}
font-family: "${config.secondLineFont}", sans-serif;
font-family: "${config.secondLineFont}"${FALLBACK_FONTS};
}
yt-live-chat-mode-change-message-renderer,
@@ -382,11 +384,11 @@ function getAnimationStyle (config) {
: ` transform: translateX(${config.reverseSlide ? 16 : -16}px);`
} }`)
curTime += config.fadeInTime
keyframes.push(` ${(curTime / totalTime) * 100}% { opacity: 1; transform: none;}`)
keyframes.push(` ${(curTime / totalTime) * 100}% { opacity: 1; transform: none; }`)
}
if (config.animateOut) {
curTime += config.animateOutWaitTime * 1000
keyframes.push(` ${(curTime / totalTime) * 100}% { opacity: 1; transform: none;}`)
keyframes.push(` ${(curTime / totalTime) * 100}% { opacity: 1; transform: none; }`)
curTime += config.fadeOutTime
keyframes.push(` ${(curTime / totalTime) * 100}% { opacity: 0;${!config.slide ? ''
: ` transform: translateX(${config.reverseSlide ? -16 : 16}px);`
@@ -402,10 +404,3 @@ yt-live-chat-legacy-paid-message-renderer {
animation-fill-mode: both;
}`
}
export default {
DEFAULT_CONFIG,
setLocalConfig,
getLocalConfig,
getStyle
}

72
main.py
View File

@@ -1,54 +1,84 @@
# -*- coding: utf-8 -*-
import argparse
import webbrowser
import logging
import os
import webbrowser
import tornado.ioloop
import tornado.web
import views.chat
import views.config
import views.main
import api.chat
import api.main
import config
import models.avatar
import models.database
import models.translate
import update
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
)
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,
def run_server(host, port, debug):
app = tornado.web.Application(
routes,
websocket_ping_interval=10,
debug=debug,
autoreload=False
)
app.listen(args.port, args.host)
logger.info('服务器启动:%s:%d', args.host, args.port)
url = 'http://localhost' if args.port == 80 else f'http://localhost:{args.port}'
webbrowser.open(url)
try:
app.listen(port, host)
except OSError:
logger.warning('Address is used %s:%d', host, port)
return
finally:
url = 'http://localhost/' if port == 80 else f'http://localhost:{port}/'
# 防止更新版本后浏览器加载缓存
url += '?_v=' + update.VERSION
webbrowser.open(url)
logger.info('Server started: %s:%d', host, port)
tornado.ioloop.IOLoop.current().start()

204
models/avatar.py Normal file
View File

@@ -0,0 +1,204 @@
# -*- 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] = {}
# 正在获取头像的Futureuser_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
while len(_avatar_url_cache) > 50000:
_avatar_url_cache.pop(next(iter(_avatar_url_cache)), None)
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)

35
models/database.py Normal file
View File

@@ -0,0 +1,35 @@
# -*- 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)
engine = sqlalchemy.create_engine(cfg.database_url)
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()

413
models/translate.py Normal file
View File

@@ -0,0 +1,413 @@
# -*- 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] = {}
# 正在翻译的Futuretext -> 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
while len(_translate_cache) > 50000:
_translate_cache.pop(next(iter(_translate_cache)), None)
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):
# 过期时间1小时
self._qtv = ''
self._qtk = ''
self._reinit_future = None
# 连续失败的次数
self._fail_count = 0
self._cool_down_future = None
async def init(self):
if self._reinit_future is not None:
self._reinit_future.cancel()
self._reinit_future = None
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
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
qtk = m[1]
self._qtv = qtv
self._qtk = qtk
self._reinit_future = asyncio.ensure_future(self._reinit_coroutine())
return True
async def _reinit_coroutine(self):
try:
await asyncio.sleep(55 * 60)
await self.init()
except asyncio.CancelledError:
pass
@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
res = ''.join(record['targetText'] for record in data['translate']['records'])
if res == '' and text.strip() != '':
# qtv、qtk过期
logger.warning('TencentTranslate result is empty %s', data)
return None
return res
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 = await 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 = await 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']

View File

@@ -1,2 +1,3 @@
aiohttp==3.5.4
sqlalchemy==1.3.13
tornado==6.0.2

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 159 KiB

24
update.py Normal file
View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
import asyncio
import aiohttp
VERSION = 'v1.4.2'
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')

View File

@@ -1,264 +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):
JOIN_ROOM = 0
ADD_TEXT = 1
ADD_GIFT = 2
ADD_MEMBER = 3
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() < 5 * 60 + 3:
# 5分钟以内被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('获取头像失败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)
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
})
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('创建房间%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('移除房间%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连接 %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('客户端%s加入房间%d', self.request.remote_ip, self.room_id)
room_manager.add_client(self.room_id, self)
else:
logger.warning('未知的命令: %s data: %s', body['cmd'], body['data'])
def on_close(self):
logger.info('Websocket断开 %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)

View File

@@ -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',
'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)

View File

@@ -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)