├── .dockerignore
├── .gitattributes
├── .gitignore
├── .gitmodules
├── Dockerfile
├── LICENSE
├── README.md
├── api
├── base.py
├── chat.py
├── main.py
├── open_live.py
└── plugin.py
├── blcsdk
├── __init__.py
├── api.py
├── client.py
├── exc.py
├── handlers.py
├── models.py
└── requirements.txt
├── config.py
├── data
├── config.example.ini
├── custom_public
│ ├── README.txt
│ └── templates
│ │ └── .gitkeep
├── emoticons
│ └── .gitkeep
├── loader.html
└── plugins
│ └── .gitkeep
├── frontend
├── .env
├── .env.common_server
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── babel.config.js
├── jsconfig.json
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ └── static
│ │ └── img
│ │ ├── emoticons
│ │ ├── 233.png
│ │ ├── huangdou_xihuan.png
│ │ ├── lipu.png
│ │ ├── miaoa.png
│ │ └── sakaban_jiayu_yutou.png
│ │ ├── icons
│ │ ├── guard-level-1.png
│ │ ├── guard-level-2.png
│ │ └── guard-level-3.png
│ │ └── tutorial
│ │ ├── tutorial-1.jpg
│ │ ├── tutorial-2.jpg
│ │ ├── tutorial-3.jpg
│ │ ├── tutorial-4.jpg
│ │ └── tutorial-5.jpg
├── src
│ ├── App.vue
│ ├── api
│ │ ├── base.js
│ │ ├── chat
│ │ │ ├── ChatClientDirectOpenLive.js
│ │ │ ├── ChatClientDirectWeb.js
│ │ │ ├── ChatClientOfficialBase
│ │ │ │ ├── brotli_decode.js
│ │ │ │ └── index.js
│ │ │ ├── ChatClientRelay.js
│ │ │ ├── ChatClientTest.js
│ │ │ ├── index.js
│ │ │ └── models.js
│ │ ├── chatConfig.js
│ │ ├── main.js
│ │ └── plugins.js
│ ├── assets
│ │ ├── css
│ │ │ └── youtube
│ │ │ │ ├── yt-html.css
│ │ │ │ ├── yt-icon.css
│ │ │ │ ├── yt-img-shadow.css
│ │ │ │ ├── yt-live-chat-author-badge-renderer.css
│ │ │ │ ├── yt-live-chat-author-chip.css
│ │ │ │ ├── yt-live-chat-item-list-renderer.css
│ │ │ │ ├── yt-live-chat-membership-item-renderer.css
│ │ │ │ ├── yt-live-chat-paid-message-renderer.css
│ │ │ │ ├── yt-live-chat-renderer.css
│ │ │ │ ├── yt-live-chat-text-message-renderer.css
│ │ │ │ ├── yt-live-chat-ticker-paid-message-item-renderer.css
│ │ │ │ └── yt-live-chat-ticker-renderer.css
│ │ └── img
│ │ │ └── logo.png
│ ├── blcsdk.js
│ ├── components
│ │ └── ChatRenderer
│ │ │ ├── AuthorBadge.vue
│ │ │ ├── AuthorChip.vue
│ │ │ ├── ImgShadow.vue
│ │ │ ├── MembershipItem.vue
│ │ │ ├── PaidMessage.vue
│ │ │ ├── TextMessage.vue
│ │ │ ├── Ticker.vue
│ │ │ ├── constants.js
│ │ │ └── index.vue
│ ├── i18n.js
│ ├── lang
│ │ ├── en.js
│ │ ├── ja.js
│ │ └── zh.js
│ ├── layout
│ │ ├── Sidebar.vue
│ │ └── index.vue
│ ├── main.js
│ ├── utils
│ │ ├── index.js
│ │ ├── pronunciation
│ │ │ ├── dictKana.js
│ │ │ ├── dictPinyin.js
│ │ │ └── index.js
│ │ └── trie.js
│ └── views
│ │ ├── Help.vue
│ │ ├── Home
│ │ ├── TemplateSelect.vue
│ │ └── index.vue
│ │ ├── NotFound.vue
│ │ ├── Plugins.vue
│ │ ├── Room.vue
│ │ └── StyleGenerator
│ │ ├── FontSelect.vue
│ │ ├── Legacy.vue
│ │ ├── LineLike.vue
│ │ ├── common.js
│ │ ├── fonts.js
│ │ └── index.vue
├── vercel.json
└── vue.config.js
├── log
└── .gitkeep
├── main.py
├── models
├── bilibili.py
└── database.py
├── plugins
├── msg-logging
│ ├── LICENSE
│ ├── config.py
│ ├── listener.py
│ ├── log
│ │ └── .gitkeep
│ ├── main.py
│ ├── msg-logging.spec
│ └── plugin.json
├── native-ui
│ ├── LICENSE
│ ├── config.py
│ ├── data
│ │ ├── blivechat.ico
│ │ └── config.example.ini
│ ├── designer
│ │ ├── native-ui.fbp
│ │ └── ui_base.py
│ ├── listener.py
│ ├── log
│ │ └── .gitkeep
│ ├── main.pyw
│ ├── native-ui.spec
│ ├── plugin.json
│ ├── requirements.txt
│ └── ui
│ │ ├── room_config_dialog.py
│ │ ├── room_frame.py
│ │ └── task_bar_icon.py
└── text-to-speech
│ ├── LICENSE
│ ├── config.py
│ ├── data
│ └── config.example.ini
│ ├── listener.py
│ ├── log
│ └── .gitkeep
│ ├── main.py
│ ├── plugin.json
│ ├── requirements.txt
│ ├── text-to-speech.spec
│ └── tts.py
├── requirements.txt
├── screenshots
├── chrome.png
├── obs.png
└── stylegen.png
├── services
├── avatar.py
├── chat.py
├── open_live.py
├── plugin.py
└── translate.py
├── update.py
└── utils
├── async_io.py
├── rate_limit.py
└── request.py
/.dockerignore:
--------------------------------------------------------------------------------
1 | # compiled output
2 | **/__pycache__/
3 | **/build/
4 | **/dist/
5 |
6 | # dependencies
7 | frontend/package-lock.json
8 | **/node_modules/
9 |
10 | # IDEs and editors
11 | **/.idea/
12 |
13 | # misc
14 | **/.git*
15 | **/*.spec
16 | screenshots/
17 |
18 | plugins/
19 |
20 | # runtime data
21 | data/*
22 | !data/config.example.ini
23 | !data/loader.html
24 | !data/custom_public/
25 | data/custom_public/*
26 | !data/custom_public/README.txt
27 | !data/custom_public/templates/
28 | data/custom_public/templates/*
29 | !data/emoticons/
30 | data/emoticons/*
31 | !data/plugins/
32 | data/plugins/*
33 | log/*
34 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 | MANIFEST
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | htmlcov/
40 | .tox/
41 | .coverage
42 | .coverage.*
43 | .cache
44 | nosetests.xml
45 | coverage.xml
46 | *.cover
47 | .hypothesis/
48 | .pytest_cache/
49 |
50 | # Translations
51 | *.mo
52 | *.pot
53 |
54 | # Django stuff:
55 | *.log
56 | local_settings.py
57 | db.sqlite3
58 |
59 | # Flask stuff:
60 | instance/
61 | .webassets-cache
62 |
63 | # Scrapy stuff:
64 | .scrapy
65 |
66 | # Sphinx documentation
67 | docs/_build/
68 |
69 | # PyBuilder
70 | target/
71 |
72 | # Jupyter Notebook
73 | .ipynb_checkpoints
74 |
75 | # pyenv
76 | .python-version
77 |
78 | # celery beat schedule file
79 | celerybeat-schedule
80 |
81 | # SageMath parsed files
82 | *.sage.py
83 |
84 | # Environments
85 | .env
86 | .venv
87 | env/
88 | venv/
89 | ENV/
90 | env.bak/
91 | venv.bak/
92 |
93 | # Spyder project settings
94 | .spyderproject
95 | .spyproject
96 |
97 | # Rope project settings
98 | .ropeproject
99 |
100 | # mkdocs documentation
101 | /site
102 |
103 | # mypy
104 | .mypy_cache/
105 |
106 |
107 | .idea/
108 | data/
109 | log/
110 | .vercel
111 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "blivedm"]
2 | path = blivedm
3 | url = https://github.com/xfgryujk/blivedm.git
4 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | #
2 | # 构建前端
3 | #
4 |
5 | FROM node:18.17.0-bullseye AS builder
6 | ARG BASE_PATH='/root/blivechat'
7 | WORKDIR "${BASE_PATH}/frontend"
8 |
9 | # 前端依赖
10 | COPY frontend/package.json ./
11 | RUN npm i --registry=https://registry.npmmirror.com
12 |
13 | # 编译前端
14 | COPY frontend ./
15 | RUN npm run build
16 |
17 | #
18 | # 准备后端
19 | #
20 |
21 | FROM python:3.12.10-bookworm
22 | ARG BASE_PATH='/root/blivechat'
23 | ARG EXT_DATA_PATH='/mnt/data'
24 | WORKDIR "${BASE_PATH}"
25 |
26 | # 后端依赖
27 | COPY blivedm/requirements.txt blivedm/
28 | COPY requirements.txt ./
29 | RUN pip3 install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple -r requirements.txt
30 |
31 | # 数据目录
32 | COPY . ./
33 | RUN sed 's/^host =.*$/host = 0.0.0.0/; s/^loader_url =.*$/loader_url =/' data/config.example.ini >> data/config.ini
34 | RUN mkdir -p "${EXT_DATA_PATH}" \
35 | && mv data "${EXT_DATA_PATH}/data" \
36 | && ln -s "${EXT_DATA_PATH}/data" data \
37 | && mv log "${EXT_DATA_PATH}/log" \
38 | && ln -s "${EXT_DATA_PATH}/log" log
39 |
40 | # 编译好的前端
41 | COPY --from=builder "${BASE_PATH}/frontend/dist" frontend/dist
42 |
43 | # 运行
44 | VOLUME "${EXT_DATA_PATH}"
45 | EXPOSE 12450
46 | ENTRYPOINT ["python3", "main.py"]
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 xfgryujk
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # blivechat
2 |
3 | 用于OBS的仿YouTube风格的bilibili直播评论栏
4 |
5 | 
6 |
7 | 
8 |
9 | 
10 |
11 | ## 特性
12 |
13 | * 兼容YouTube直播评论栏的样式
14 | * 高亮舰队、房管、主播的用户名
15 | * 自带两种样式生成器,经典YouTube风格和仿微信风格
16 | * 支持屏蔽弹幕、合并礼物等设置
17 | * 支持前端直连B站服务器或者通过后端转发
18 | * 支持自动翻译弹幕、醒目留言到日语,可以在后台配置翻译目标语言
19 | * 支持标注打赏用户名的读音,可选拼音或日文假名
20 | * 支持配置自定义表情,不需要开通B站官方表情
21 | * 支持[自定义HTML模板](https://github.com/xfgryujk/blivechat/wiki/%E8%87%AA%E5%AE%9A%E4%B9%89HTML%E6%A8%A1%E6%9D%BF)
22 | * 支持[插件开发](https://github.com/xfgryujk/blivechat/wiki/%E6%8F%92%E4%BB%B6%E7%B3%BB%E7%BB%9F)
23 |
24 | ## 使用方法
25 |
26 | 以下几种方式任选一种即可。**正式使用之前记得看[注意事项](https://github.com/xfgryujk/blivechat/wiki/%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9%E5%92%8C%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98)**
27 |
28 | 推荐的方式:如果你需要使用插件、翻译等高级特性,则在本地使用;否则推荐直接通过公共服务器在线使用。因为本地使用时不会自动升级版本,有时候出了问题不能及时解决;但在线使用时会禁用部分高级特性,如果你有需要,只能本地使用了
29 |
30 | ### 一、在线使用
31 |
32 | 1. 这些是作者维护的公共服务器,根据情况随便选一个,直接用浏览器打开
33 |
34 | * [blive.chat](https://blive.chat/):自动节点,一般等于cn.blive.chat,如果不可用了会进行切换,但切换需要一段时间
35 | * [cn.blive.chat](https://cn.blive.chat/):墙内专用节点,不容易被墙,但如果受到攻击会变得不可用
36 | * [cloudflare.blive.chat](https://cloudflare.blive.chat/):Cloudflare美国节点,不容易被攻击,但容易被墙
37 | * [vercel.blive.chat](https://vercel.blive.chat/):Vercel美国节点,不容易被攻击,但容易被墙
38 |
39 | 2. 输入主播在开始直播时获得的身份码,复制房间URL
40 | 3. 用样式生成器生成样式,复制CSS
41 | 4. 在OBS中添加浏览器源,输入URL和自定义CSS
42 |
43 | ### 二、本地使用
44 |
45 | 1. 下载[本地分发版](https://github.com/xfgryujk/blivechat/releases)(仅提供x64 Windows版)。也可以在[B站商店](https://play-live.bilibili.com/details/1694397161340)下载
46 | 2. 双击`blivechat.exe`(或者`start.exe`)运行服务器
47 | 3. 用浏览器打开[http://localhost:12450](http://localhost:12450),剩下的步骤和在线使用时是一样的
48 |
49 | ### 三、从源码运行
50 |
51 | 此方式适用于自建服务器或者在Windows以外的平台运行
52 |
53 | 0. 由于使用了git子模块,clone时需要加上`--recursive`参数:
54 |
55 | ```sh
56 | git clone --recursive https://github.com/xfgryujk/blivechat.git
57 | ```
58 |
59 | 如果已经clone,拉子模块的方法:
60 |
61 | ```sh
62 | git submodule update --init --recursive
63 | ```
64 |
65 | 1. 编译前端(需要安装Node.js):
66 |
67 | ```sh
68 | cd frontend
69 | npm i
70 | npm run build
71 | ```
72 |
73 | 2. 安装服务器依赖(需要Python 3.12以上版本):
74 |
75 | ```sh
76 | pip install -r requirements.txt
77 | ```
78 |
79 | 3. 运行服务器:
80 |
81 | ```sh
82 | python main.py
83 | ```
84 |
85 | 或者可以指定host和端口号:
86 |
87 | ```sh
88 | python main.py --host 127.0.0.1 --port 12450
89 | ```
90 |
91 | 4. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
92 |
93 | ### 四、Docker
94 |
95 | 此方式适用于自建服务器。示例的运行参数只是最基本的,可以根据需要修改
96 |
97 | 1. ```sh
98 | docker run --name blivechat -d -p 12450:12450 \
99 | --mount source=blivechat-data,target=/mnt/data \
100 | xfgryujk/blivechat:latest
101 | ```
102 |
103 | 2. 用浏览器打开[http://localhost:12450](http://localhost:12450),以下略
104 |
105 | ## 服务器配置
106 |
107 | 服务器配置文件在`data/config.ini`,可以配置数据库和允许自动翻译等,编辑后要重启生效
108 |
109 | **自建服务器时注意要删除loader_url配置**,否则加载不了房间页面
110 |
111 | ## 常用链接
112 |
113 | * [文档](https://github.com/xfgryujk/blivechat/wiki)
114 | * [交流社区](https://github.com/xfgryujk/blivechat/discussions)
115 | * [B站商店](https://play-live.bilibili.com/details/1694397161340)
116 |
--------------------------------------------------------------------------------
/api/base.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import json
3 | from typing import *
4 |
5 | import tornado.web
6 |
7 | import config
8 |
9 |
10 | class ApiHandler(tornado.web.RequestHandler):
11 | def __init__(self, *args, **kwargs):
12 | super().__init__(*args, **kwargs)
13 | self.json_args: Optional[dict] = None
14 |
15 | def set_default_headers(self):
16 | self.set_header('Cache-Control', 'no-cache')
17 |
18 | self.add_header('Vary', 'Origin')
19 | origin = self.request.headers.get('Origin', None)
20 | if origin is None:
21 | return
22 | cfg = config.get_config()
23 | if not cfg.is_allowed_cors_origin(origin):
24 | return
25 |
26 | self.set_header('Access-Control-Allow-Origin', origin)
27 | self.set_header('Access-Control-Allow-Methods', '*')
28 | self.set_header('Access-Control-Allow-Headers', '*')
29 | self.set_header('Access-Control-Max-Age', '3600')
30 |
31 | def prepare(self):
32 | if not self.request.headers.get('Content-Type', '').startswith('application/json'):
33 | return
34 | try:
35 | self.json_args = json.loads(self.request.body)
36 | except json.JSONDecodeError:
37 | pass
38 |
39 | async def options(self, *_args, **_kwargs):
40 | self.set_status(204)
41 |
--------------------------------------------------------------------------------
/blcsdk/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | __version__ = '1.0.0'
3 |
4 | from .handlers import *
5 | from .client import *
6 | from .exc import *
7 | from .api import *
8 |
--------------------------------------------------------------------------------
/blcsdk/exc.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from typing import *
3 |
4 | __all__ = (
5 | 'SdkError',
6 | 'InitError',
7 | 'TransportError',
8 | 'ResponseError',
9 | )
10 |
11 |
12 | class SdkError(Exception):
13 | """SDK错误的基类"""
14 |
15 |
16 | class InitError(SdkError):
17 | """初始化失败"""
18 |
19 |
20 | class TransportError(SdkError):
21 | """通信错误"""
22 |
23 |
24 | class ResponseError(SdkError):
25 | """响应代码错误"""
26 | def __init__(self, code: int, msg: str, data: Optional[dict] = None):
27 | super().__init__(f'code={code}, msg={msg}, data={data}')
28 | self.code = code
29 | self.msg = msg
30 | self.data = data
31 |
--------------------------------------------------------------------------------
/blcsdk/handlers.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from typing import *
3 |
4 | from . import client as cli
5 | from . import models
6 |
7 | __all__ = (
8 | 'HandlerInterface',
9 | 'BaseHandler',
10 | )
11 |
12 |
13 | class HandlerInterface:
14 | """blivechat插件消息处理器接口"""
15 |
16 | def handle(self, client: cli.BlcPluginClient, command: dict):
17 | raise NotImplementedError
18 |
19 | def on_client_stopped(self, client: cli.BlcPluginClient, exception: Optional[Exception]):
20 | """
21 | 当客户端停止时调用
22 |
23 | 这种情况说明blivechat已经退出了,或者插件被禁用了,因此重连基本会失败。这里唯一建议的操作是退出当前程序
24 | """
25 |
26 |
27 | def _make_msg_callback(method_name, message_cls):
28 | def callback(self: 'BaseHandler', client: cli.BlcPluginClient, command: dict):
29 | method = getattr(self, method_name)
30 | msg = message_cls.from_command(command['data'])
31 | extra = models.ExtraData.from_dict(command.get('extra', {}))
32 | return method(client, msg, extra)
33 | return callback
34 |
35 |
36 | class BaseHandler(HandlerInterface):
37 | """一个简单的消息处理器实现,带消息分发和消息类型转换。继承并重写_on_xxx方法即可实现自己的处理器"""
38 |
39 | _CMD_CALLBACK_DICT: Dict[
40 | int,
41 | Optional[Callable[
42 | ['BaseHandler', cli.BlcPluginClient, dict],
43 | Any
44 | ]]
45 | ] = {
46 | models.Command.ADD_ROOM: _make_msg_callback('_on_add_room', models.AddRoomMsg),
47 | models.Command.ROOM_INIT: _make_msg_callback('_on_room_init', models.RoomInitMsg),
48 | models.Command.DEL_ROOM: _make_msg_callback('_on_del_room', models.DelRoomMsg),
49 | models.Command.OPEN_PLUGIN_ADMIN_UI: _make_msg_callback(
50 | '_on_open_plugin_admin_ui', models.OpenPluginAdminUiMsg
51 | ),
52 | models.Command.ADD_TEXT: _make_msg_callback('_on_add_text', models.AddTextMsg),
53 | models.Command.ADD_GIFT: _make_msg_callback('_on_add_gift', models.AddGiftMsg),
54 | models.Command.ADD_MEMBER: _make_msg_callback('_on_add_member', models.AddMemberMsg),
55 | models.Command.ADD_SUPER_CHAT: _make_msg_callback('_on_add_super_chat', models.AddSuperChatMsg),
56 | models.Command.DEL_SUPER_CHAT: _make_msg_callback('_on_del_super_chat', models.DelSuperChatMsg),
57 | models.Command.UPDATE_TRANSLATION: _make_msg_callback('_on_update_translation', models.UpdateTranslationMsg),
58 | }
59 | """cmd -> 处理回调"""
60 |
61 | def handle(self, client: cli.BlcPluginClient, command: dict):
62 | cmd = command['cmd']
63 | callback = self._CMD_CALLBACK_DICT.get(cmd, None)
64 | if callback is not None:
65 | callback(self, client, command)
66 |
67 | def _on_add_room(self, client: cli.BlcPluginClient, message: models.AddRoomMsg, extra: models.ExtraData):
68 | """添加房间"""
69 |
70 | def _on_room_init(self, client: cli.BlcPluginClient, message: models.RoomInitMsg, extra: models.ExtraData):
71 | """房间初始化"""
72 |
73 | def _on_del_room(self, client: cli.BlcPluginClient, message: models.DelRoomMsg, extra: models.ExtraData):
74 | """删除房间"""
75 |
76 | def _on_open_plugin_admin_ui(
77 | self, client: cli.BlcPluginClient, message: models.OpenPluginAdminUiMsg, extra: models.ExtraData
78 | ):
79 | """用户请求打开当前插件的管理界面"""
80 |
81 | def _on_add_text(self, client: cli.BlcPluginClient, message: models.AddTextMsg, extra: models.ExtraData):
82 | """收到弹幕"""
83 |
84 | def _on_add_gift(self, client: cli.BlcPluginClient, message: models.AddGiftMsg, extra: models.ExtraData):
85 | """有人送礼"""
86 |
87 | def _on_add_member(self, client: cli.BlcPluginClient, message: models.AddMemberMsg, extra: models.ExtraData):
88 | """有人上舰"""
89 |
90 | def _on_add_super_chat(self, client: cli.BlcPluginClient, message: models.AddSuperChatMsg, extra: models.ExtraData):
91 | """醒目留言"""
92 |
93 | def _on_del_super_chat(self, client: cli.BlcPluginClient, message: models.DelSuperChatMsg, extra: models.ExtraData):
94 | """删除醒目留言"""
95 |
96 | def _on_update_translation(
97 | self, client: cli.BlcPluginClient, message: models.UpdateTranslationMsg, extra: models.ExtraData
98 | ):
99 | """更新翻译"""
100 |
--------------------------------------------------------------------------------
/blcsdk/requirements.txt:
--------------------------------------------------------------------------------
1 | aiohttp~=3.9.0
2 | pyinstaller~=5.13.2
3 |
--------------------------------------------------------------------------------
/data/custom_public/README.txt:
--------------------------------------------------------------------------------
1 | 这个目录用来暴露一些自定义文件给前端,例如图片、CSS、字体等
2 | 你可以在网页路径 “/custom_public/README.txt” 访问到这个文件
3 | 警告:不要在这个目录里存放密钥等敏感信息,因为可能会被远程访问
4 |
5 | 这个目录表下的 “preset.css” 文件是服务器预设CSS文件,你可以手动创建它
6 | 前端可以通过“导入服务器预设CSS”选项自动导入这个CSS,而不需要在OBS里面设置自定义CSS
7 |
8 |
9 | This directory is used to expose some custom files to the front end, such as images, CSS, fonts, etc.
10 | You can access this file on the web path: "/custom_public/README.txt"
11 | WARNING: DO NOT store sensitive information such as secrets in this directory, because it may be accessed by remote
12 |
13 | The "preset.css" file in this directory is the server preset CSS file. You can create it manually
14 | The front end can automatically import this CSS through the "Import the server preset CSS" option, instead of
15 | setting the custom CSS in OBS
16 |
--------------------------------------------------------------------------------
/data/custom_public/templates/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/data/custom_public/templates/.gitkeep
--------------------------------------------------------------------------------
/data/emoticons/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/data/emoticons/.gitkeep
--------------------------------------------------------------------------------
/data/loader.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | blivechat
6 |
7 |
8 |
9 | Loading... Please run blivechat
10 |
11 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/data/plugins/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/data/plugins/.gitkeep
--------------------------------------------------------------------------------
/frontend/.env:
--------------------------------------------------------------------------------
1 | # 第三方库用CDN引入
2 | LIB_USE_CDN=false
3 | # production环境生成source map
4 | PROD_SOURCE_MAP=true
5 | # 动态发现后端endpoint
6 | BACKEND_DISCOVERY=false
7 |
--------------------------------------------------------------------------------
/frontend/.env.common_server:
--------------------------------------------------------------------------------
1 | NODE_ENV=production
2 | LIB_USE_CDN=true
3 | PROD_SOURCE_MAP=false
4 | BACKEND_DISCOVERY=true
5 |
--------------------------------------------------------------------------------
/frontend/.eslintignore:
--------------------------------------------------------------------------------
1 | brotli_decode.js
2 | pronunciation/dict*.js
3 | blcsdk.js
4 |
--------------------------------------------------------------------------------
/frontend/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "root": true,
3 | "env": {
4 | "browser": true,
5 | "node": true
6 | },
7 | "parserOptions": {
8 | "parser": "@babel/eslint-parser"
9 | },
10 | "extends": [
11 | "plugin:vue/essential",
12 | "eslint:recommended"
13 | ],
14 | "rules": {
15 | "array-bracket-spacing": ["error", "never"], // 数组括号内不加空格
16 | "arrow-parens": ["error", "as-needed"], // 箭头函数单个参数不加括号
17 | "arrow-spacing": "error", // 箭头前后加空格
18 | "block-spacing": "error", // 块大括号内加空格
19 | "brace-style": "error", // 大括号不独占一行
20 | "comma-spacing": "error", // 逗号前面不加空格,后面加空格
21 | "comma-style": "error", // 逗号在语句后面而不是下一条的前面
22 | "computed-property-spacing": "error", // 计算属性名前后不加空格
23 | "curly": "error", // 禁止省略大括号
24 | "dot-notation": "error", // 使用点访问成员
25 | "eol-last": "error", // 文件末尾加换行符
26 | "func-call-spacing": "error", // 调用函数名和括号间不加空格
27 | "func-style": ["error", "declaration", { "allowArrowFunctions": true }], // 使用函数定义语法,而不是把函数表达式赋值到变量
28 | "indent": ["error", 2], // 缩进2空格
29 | "key-spacing": ["error", { "mode": "minimum" }],
30 | "keyword-spacing": "error", // 关键词前后加空格
31 | "lines-between-class-members": "error", // 类成员定义间加空格
32 | "max-lines-per-function": ["error", 150], // 每个函数最多行数
33 | "max-nested-callbacks": ["error", 3], // 每个函数最多嵌套回调数
34 | "new-parens": "error", // new调用构造函数加空格
35 | "no-array-constructor": "error", // 使用数组字面量,而不是数组构造函数
36 | "no-floating-decimal": "error", // 禁止省略浮点数首尾的0
37 | "no-implicit-coercion": "error", // 禁止隐式转换
38 | "no-empty": ["error", { "allowEmptyCatch": true }], // 禁止空的块,除了catch
39 | "no-extra-parens": ["error", "all", { "nestedBinaryExpressions": false }], // 禁止多余的括号
40 | "no-labels": "error", // 禁止使用标签
41 | "no-lone-blocks": "error", // 禁止没用的块
42 | "no-mixed-operators": "error", // 禁止混用不同优先级的操作符而不加括号
43 | "no-multi-spaces": ["error", { "ignoreEOLComments": true }], // 禁止多个空格,除了行尾注释前
44 | "no-multiple-empty-lines": "error", // 最多2个连续空行
45 | "no-nested-ternary": "error", // 禁止嵌套三元表达式
46 | "no-sequences": "error", // 禁止使用逗号操作符
47 | "no-tabs": "error", // 禁止使用tab
48 | "no-trailing-spaces": ["error", { "skipBlankLines": true }], // 禁止行尾的空格,除了空行
49 | "no-unused-expressions": "error", // 禁止没用的表达式
50 | "no-useless-concat": "error", // 禁止没用的字符串连接
51 | "no-useless-rename": "error", // 禁止没用的模块导入重命名、解构赋值重命名
52 | "no-useless-return": "error", // 禁止没用的return
53 | "no-var": "error", // 禁止使用var声明变量
54 | "no-void": "error", // 禁止使用void
55 | "no-whitespace-before-property": "error", // 禁止访问属性的点前后加空格
56 | "object-curly-spacing": ["error", "always"], // 对象字面量括号内加空格
57 | "operator-assignment": "error", // 尽量使用+=
58 | "operator-linebreak": ["error", "before"], // 操作符放行首
59 | "prefer-object-spread": "error", // 使用{...obj},而不是Object.assign
60 | "prefer-rest-params": "error", // 使用...args,而不是arguments
61 | "prefer-spread": "error", // 使用func(...args),而不是apply
62 | "prefer-template": "error", // 使用模板字符串,而不是字符串连接
63 | "rest-spread-spacing": ["error", "never"], // 解包操作符不加空格
64 | "semi": ["error", "never"], // 禁止使用多余的分号
65 | "semi-spacing": "error", // 分号前面不加空格,后面加空格
66 | "semi-style": "error", // 分号在语句后面而不是下一条的前面
67 | "space-before-blocks": "error", // 块大括号前加空格
68 | "space-before-function-paren": ["error", "never"], // 函数定义名称和括号间不加空格
69 | "space-in-parens": "error", // 括号内不加空格
70 | "space-infix-ops": "error", // 二元操作符前后加空格
71 | "space-unary-ops": "error", // 关键词一元操作符后加空格,符号一元操作符不加
72 | "spaced-comment": ["error", "always", { "block": { "balanced": true } }], // 注释前面加空格
73 | "template-curly-spacing": "error", // 模板字符串中变量大括号内不加空格
74 |
75 | "no-shadow": "warn", // 变量名和外部作用域重复
76 |
77 | "no-console": "off", // 线上尽量不要用console输出,看不到的
78 |
79 | "vue/multi-word-component-names": "off", // Vue组件名允许用1个单词
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 |
5 | # local env files
6 | .env.local
7 | .env.*.local
8 | !.env
9 |
10 | # Log files
11 | npm-debug.log*
12 | yarn-debug.log*
13 | yarn-error.log*
14 |
15 | # Editor directories and files
16 | .idea
17 | .vscode
18 | *.suo
19 | *.ntvs*
20 | *.njsproj
21 | *.sln
22 | *.sw?
23 |
24 |
25 | package-lock.json
26 |
--------------------------------------------------------------------------------
/frontend/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | '@vue/cli-plugin-babel/preset'
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2015",
4 | "module": "esnext",
5 | "baseUrl": "./",
6 | "paths": {
7 | "@/*": ["src/*"]
8 | }
9 | },
10 | "vueCompilerOptions": {
11 | "target": 2.7,
12 | },
13 | "include": [
14 | "./src/**/*.js",
15 | "./src/**/*.vue"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "blivechat",
3 | "version": "1.10.1-dev",
4 | "private": true,
5 | "scripts": {
6 | "serve": "vue-cli-service serve",
7 | "build": "vue-cli-service build",
8 | "build_common_server": "vue-cli-service build --mode common_server",
9 | "lint": "vue-cli-service lint"
10 | },
11 | "dependencies": {
12 | "axios": "^1.4.0",
13 | "core-js": "^3.8.3",
14 | "crypto-js": "^4.2.0",
15 | "downloadjs": "^1.4.7",
16 | "element-ui": "^2.15.13",
17 | "lodash": "^4.17.21",
18 | "opossum": "^8.3.0",
19 | "pako": "^2.1.0",
20 | "vue": "^2.7.14",
21 | "vue-i18n": "^8.28.2",
22 | "vue-router": "^3.6.5"
23 | },
24 | "devDependencies": {
25 | "@babel/core": "^7.12.16",
26 | "@babel/eslint-parser": "^7.12.16",
27 | "@vue/cli-plugin-babel": "~5.0.0",
28 | "@vue/cli-plugin-eslint": "~5.0.0",
29 | "@vue/cli-service": "~5.0.0",
30 | "eslint": "^7.32.0",
31 | "eslint-plugin-vue": "^9.16.1"
32 | },
33 | "browserslist": [
34 | "> 1%",
35 | "last 2 versions",
36 | "not dead"
37 | ]
38 | }
39 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | blivechat
13 |
14 | <%
15 | if (process.env.LIB_USE_CDN) {
16 | if (process.env.NODE_ENV === 'production') {
17 | %>
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | <% } else { %>
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | <%
34 | }
35 | }
36 | %>
37 |
38 |
39 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/frontend/public/static/img/emoticons/233.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/frontend/public/static/img/emoticons/233.png
--------------------------------------------------------------------------------
/frontend/public/static/img/emoticons/huangdou_xihuan.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/frontend/public/static/img/emoticons/huangdou_xihuan.png
--------------------------------------------------------------------------------
/frontend/public/static/img/emoticons/lipu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/frontend/public/static/img/emoticons/lipu.png
--------------------------------------------------------------------------------
/frontend/public/static/img/emoticons/miaoa.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/frontend/public/static/img/emoticons/miaoa.png
--------------------------------------------------------------------------------
/frontend/public/static/img/emoticons/sakaban_jiayu_yutou.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/frontend/public/static/img/emoticons/sakaban_jiayu_yutou.png
--------------------------------------------------------------------------------
/frontend/public/static/img/icons/guard-level-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/frontend/public/static/img/icons/guard-level-1.png
--------------------------------------------------------------------------------
/frontend/public/static/img/icons/guard-level-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/frontend/public/static/img/icons/guard-level-2.png
--------------------------------------------------------------------------------
/frontend/public/static/img/icons/guard-level-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/frontend/public/static/img/icons/guard-level-3.png
--------------------------------------------------------------------------------
/frontend/public/static/img/tutorial/tutorial-1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/frontend/public/static/img/tutorial/tutorial-1.jpg
--------------------------------------------------------------------------------
/frontend/public/static/img/tutorial/tutorial-2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/frontend/public/static/img/tutorial/tutorial-2.jpg
--------------------------------------------------------------------------------
/frontend/public/static/img/tutorial/tutorial-3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/frontend/public/static/img/tutorial/tutorial-3.jpg
--------------------------------------------------------------------------------
/frontend/public/static/img/tutorial/tutorial-4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/frontend/public/static/img/tutorial/tutorial-4.jpg
--------------------------------------------------------------------------------
/frontend/public/static/img/tutorial/tutorial-5.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/frontend/public/static/img/tutorial/tutorial-5.jpg
--------------------------------------------------------------------------------
/frontend/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
12 |
13 |
31 |
--------------------------------------------------------------------------------
/frontend/src/api/base.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import _ from 'lodash'
3 | import CircuitBreaker from 'opossum'
4 |
5 | axios.defaults.timeout = 10 * 1000
6 |
7 | export const apiClient = axios.create({
8 | timeout: 10 * 1000,
9 | })
10 |
11 | export let init
12 | export let getBaseUrl
13 | if (!process.env.BACKEND_DISCOVERY) {
14 | init = async function() {}
15 |
16 | const onRequest = config => {
17 | config.baseURL = getBaseUrl()
18 | return config
19 | }
20 |
21 | const onRequestError = e => {
22 | throw e
23 | }
24 |
25 | apiClient.interceptors.request.use(onRequest, onRequestError, { synchronous: true })
26 |
27 | getBaseUrl = function() {
28 | return window.location.origin
29 | }
30 |
31 | } else {
32 | init = async function() {
33 | return updateBaseUrls()
34 | }
35 |
36 | const onRequest = config => {
37 | let baseUrl = getBaseUrl()
38 | if (baseUrl === null) {
39 | throw new Error('No available endpoint')
40 | }
41 | config.baseURL = baseUrl
42 | return config
43 | }
44 |
45 | const onRequestError = e => {
46 | throw e
47 | }
48 |
49 | const onResponse = response => {
50 | let promise = Promise.resolve(response)
51 | let baseUrl = response.config.baseURL
52 | let breaker = getOrAddCircuitBreaker(baseUrl)
53 | breaker.fire(promise).catch(() => {})
54 | return response
55 | }
56 |
57 | const onResponseError = e => {
58 | let promise = Promise.reject(e)
59 | if (!e.response || (500 <= e.response.status && e.response.status < 600)) {
60 | let baseUrl = e.config.baseURL
61 | let breaker = getOrAddCircuitBreaker(baseUrl)
62 | breaker.fire(promise).catch(() => {})
63 | }
64 | return promise
65 | }
66 |
67 | apiClient.interceptors.request.use(onRequest, onRequestError, { synchronous: true })
68 | apiClient.interceptors.response.use(onResponse, onResponseError)
69 |
70 | const DISCOVERY_URLS = process.env.NODE_ENV === 'production' ? [
71 | // 只有公共服务器会开BACKEND_DISCOVERY,这里可以直接跨域访问
72 | 'https://api1.blive.chat/api/endpoints',
73 | 'https://api2.blive.chat/api/endpoints',
74 | ] : [
75 | `${window.location.origin}/api/endpoints`,
76 | 'http://localhost:12450/api/endpoints',
77 | ]
78 | let baseUrls = process.env.NODE_ENV === 'production' ? [
79 | 'https://api1.blive.chat',
80 | 'https://api2.blive.chat',
81 | ] : [
82 | window.location.origin,
83 | 'http://localhost:12450',
84 | ]
85 | let curBaseUrl = null
86 | let baseUrlToCircuitBreaker = new Map()
87 |
88 | const doUpdateBaseUrls = async() => {
89 | async function requestGetUrls(discoveryUrl) {
90 | try {
91 | return (await axios.get(discoveryUrl)).data.endpoints
92 | } catch (e) {
93 | console.warn('Failed to discover server endpoints from one source:', e)
94 | throw e
95 | }
96 | }
97 |
98 | let _baseUrls = []
99 | try {
100 | let promises = DISCOVERY_URLS.map(requestGetUrls)
101 | _baseUrls = await Promise.any(promises)
102 | } catch {
103 | }
104 | if (_baseUrls.length === 0) {
105 | console.error('Failed to discover server endpoints from any source')
106 | return
107 | }
108 |
109 | // 按响应时间排序
110 | let sortedBaseUrls = []
111 | let errorBaseUrls = []
112 |
113 | async function testEndpoint(baseUrl) {
114 | try {
115 | let url = `${baseUrl}/api/ping`
116 | await axios.get(url, { timeout: 3 * 1000 })
117 | sortedBaseUrls.push(baseUrl)
118 | } catch {
119 | errorBaseUrls.push(baseUrl)
120 | }
121 | }
122 |
123 | await Promise.all(_baseUrls.map(testEndpoint))
124 | sortedBaseUrls = sortedBaseUrls.concat(errorBaseUrls)
125 |
126 | baseUrls = sortedBaseUrls
127 | if (baseUrls.indexOf(curBaseUrl) === -1) {
128 | curBaseUrl = null
129 | }
130 |
131 | console.log('Found server endpoints:', baseUrls)
132 | }
133 | const updateBaseUrls = _.throttle(doUpdateBaseUrls, 3 * 60 * 1000)
134 |
135 | getBaseUrl = function() {
136 | updateBaseUrls()
137 |
138 | if (curBaseUrl !== null) {
139 | let breaker = getOrAddCircuitBreaker(curBaseUrl)
140 | if (!breaker.opened) {
141 | return curBaseUrl
142 | }
143 | curBaseUrl = null
144 | }
145 |
146 | // 找第一个未熔断的
147 | for (let baseUrl of baseUrls) {
148 | let breaker = getOrAddCircuitBreaker(baseUrl)
149 | if (!breaker.opened) {
150 | curBaseUrl = baseUrl
151 | console.log('Switch server endpoint to', curBaseUrl)
152 | return curBaseUrl
153 | }
154 | }
155 | return null
156 | }
157 |
158 | const getOrAddCircuitBreaker = baseUrl => {
159 | let breaker = baseUrlToCircuitBreaker.get(baseUrl)
160 | if (breaker === undefined) {
161 | breaker = new CircuitBreaker(promise => promise, {
162 | timeout: false,
163 | rollingCountTimeout: 60 * 1000,
164 | errorThresholdPercentage: 70,
165 | resetTimeout: 60 * 1000,
166 | })
167 | baseUrlToCircuitBreaker.set(baseUrl, breaker)
168 | }
169 | return breaker
170 | }
171 | }
172 |
--------------------------------------------------------------------------------
/frontend/src/api/chat/ChatClientRelay.js:
--------------------------------------------------------------------------------
1 | import { getBaseUrl } from '@/api/base'
2 | import * as chat from '.'
3 | import * as chatModels from './models'
4 |
5 | const COMMAND_HEARTBEAT = 0
6 | const COMMAND_JOIN_ROOM = 1
7 | const COMMAND_ADD_TEXT = 2
8 | const COMMAND_ADD_GIFT = 3
9 | const COMMAND_ADD_MEMBER = 4
10 | const COMMAND_ADD_SUPER_CHAT = 5
11 | const COMMAND_DEL_SUPER_CHAT = 6
12 | const COMMAND_UPDATE_TRANSLATION = 7
13 | const COMMAND_FATAL_ERROR = 8
14 |
15 | // const CONTENT_TYPE_TEXT = 0
16 | const CONTENT_TYPE_EMOTICON = 1
17 |
18 | const RECEIVE_TIMEOUT = 15 * 1000
19 |
20 | export default class ChatClientRelay {
21 | constructor(roomKey, autoTranslate) {
22 | this.roomKey = roomKey
23 | this.autoTranslate = autoTranslate
24 |
25 | this.msgHandler = chat.getDefaultMsgHandler()
26 |
27 | this.websocket = null
28 | this.retryCount = 0
29 | this.totalRetryCount = 0
30 | this.isDestroying = false
31 | this.receiveTimeoutTimerId = null
32 | }
33 |
34 | start() {
35 | this.wsConnect()
36 | }
37 |
38 | stop() {
39 | this.isDestroying = true
40 | if (this.websocket) {
41 | this.websocket.close()
42 | }
43 | }
44 |
45 | addDebugMsg(content) {
46 | this.msgHandler.onDebugMsg(new chatModels.DebugMsg({ content }))
47 | }
48 |
49 | wsConnect() {
50 | if (this.isDestroying) {
51 | return
52 | }
53 |
54 | this.addDebugMsg('Connecting')
55 |
56 | let baseUrl = getBaseUrl()
57 | if (baseUrl === null) {
58 | this.addDebugMsg('No available endpoint')
59 | window.setTimeout(() => this.onWsClose(), 0)
60 | return
61 | }
62 | let url = baseUrl.replace(/^http(s?):/, 'ws$1:')
63 | url += '/api/chat'
64 |
65 | this.websocket = new WebSocket(url)
66 | this.websocket.onopen = this.onWsOpen.bind(this)
67 | this.websocket.onclose = this.onWsClose.bind(this)
68 | this.websocket.onmessage = this.onWsMessage.bind(this)
69 | }
70 |
71 | onWsOpen() {
72 | this.addDebugMsg('Connected and authenticating')
73 |
74 | this.websocket.send(JSON.stringify({
75 | cmd: COMMAND_JOIN_ROOM,
76 | data: {
77 | roomKey: this.roomKey,
78 | config: {
79 | autoTranslate: this.autoTranslate
80 | }
81 | }
82 | }))
83 | this.refreshReceiveTimeoutTimer()
84 | }
85 |
86 | refreshReceiveTimeoutTimer() {
87 | if (this.receiveTimeoutTimerId) {
88 | window.clearTimeout(this.receiveTimeoutTimerId)
89 | }
90 | this.receiveTimeoutTimerId = window.setTimeout(this.onReceiveTimeout.bind(this), RECEIVE_TIMEOUT)
91 | }
92 |
93 | onReceiveTimeout() {
94 | this.receiveTimeoutTimerId = null
95 | console.warn('接收消息超时')
96 | this.addDebugMsg('Receiving message timed out')
97 |
98 | if (this.websocket) {
99 | if (this.websocket.onclose) {
100 | window.setTimeout(() => this.onWsClose(), 0)
101 | }
102 | // 直接丢弃阻塞的websocket,不等onclose回调了
103 | this.websocket.onopen = this.websocket.onclose = this.websocket.onmessage = null
104 | this.websocket.close()
105 | }
106 | }
107 |
108 | onWsClose() {
109 | this.addDebugMsg('Disconnected')
110 |
111 | this.websocket = null
112 | if (this.receiveTimeoutTimerId) {
113 | window.clearTimeout(this.receiveTimeoutTimerId)
114 | this.receiveTimeoutTimerId = null
115 | }
116 |
117 | if (this.isDestroying) {
118 | return
119 | }
120 | this.retryCount++
121 | this.totalRetryCount++
122 | console.warn(`掉线重连中 retryCount=${this.retryCount}, totalRetryCount=${this.totalRetryCount}`)
123 |
124 | // 防止无限重连的保险措施。30次重连大概会断线500秒,应该够了
125 | if (this.totalRetryCount > 30) {
126 | this.stop()
127 | let error = new chatModels.ChatClientFatalError(
128 | chatModels.FATAL_ERROR_TYPE_TOO_MANY_RETRIES, 'The connection has lost too many times'
129 | )
130 | this.msgHandler.onFatalError(error)
131 | return
132 | }
133 |
134 | this.addDebugMsg('Scheduling reconnection')
135 |
136 | // 这边不用判断页面是否可见,因为发心跳包不是由定时器触发的,即使是不活动页面也不会心跳超时
137 | window.setTimeout(this.wsConnect.bind(this), this.getReconnectInterval())
138 | }
139 |
140 | getReconnectInterval() {
141 | // 不用retryCount了,防止意外的连接成功,导致retryCount重置
142 | let interval = Math.min(1000 + ((this.totalRetryCount - 1) * 2000), 20 * 1000)
143 | // 加上随机延迟,防止同时请求导致雪崩
144 | interval += Math.random() * 3000
145 | return interval
146 | }
147 |
148 | onWsMessage(event) {
149 | let { cmd, data } = JSON.parse(event.data)
150 | switch (cmd) {
151 | case COMMAND_HEARTBEAT: {
152 | this.refreshReceiveTimeoutTimer()
153 |
154 | // 不能由定时器触发发心跳包,因为浏览器会把不活动页面的定时器调到1分钟以上
155 | this.websocket.send(JSON.stringify({
156 | cmd: COMMAND_HEARTBEAT
157 | }))
158 | break
159 | }
160 | case COMMAND_ADD_TEXT: {
161 | let emoticon = null
162 | let contentType = data[13]
163 | let contentTypeParams = data[14]
164 | if (contentType === CONTENT_TYPE_EMOTICON) {
165 | emoticon = contentTypeParams[0]
166 | }
167 |
168 | let content = data[4]
169 | data = new chatModels.AddTextMsg({
170 | avatarUrl: data[0],
171 | timestamp: data[1],
172 | authorName: data[2],
173 | authorType: data[3],
174 | content: content,
175 | privilegeType: data[5],
176 | isGiftDanmaku: Boolean(data[6]) || chat.isGiftDanmakuByContent(content),
177 | authorLevel: data[7],
178 | isNewbie: Boolean(data[8]),
179 | isMobileVerified: Boolean(data[9]),
180 | medalLevel: data[10],
181 | id: data[11],
182 | translation: data[12],
183 | emoticon: emoticon,
184 | uid: data[16],
185 | medalName: data[17],
186 | })
187 | this.msgHandler.onAddText(data)
188 | break
189 | }
190 | case COMMAND_ADD_GIFT: {
191 | data = new chatModels.AddGiftMsg(data)
192 | this.msgHandler.onAddGift(data)
193 | break
194 | }
195 | case COMMAND_ADD_MEMBER: {
196 | data = new chatModels.AddMemberMsg(data)
197 | this.msgHandler.onAddMember(data)
198 | break
199 | }
200 | case COMMAND_ADD_SUPER_CHAT: {
201 | data = new chatModels.AddSuperChatMsg(data)
202 | this.msgHandler.onAddSuperChat(data)
203 | break
204 | }
205 | case COMMAND_DEL_SUPER_CHAT: {
206 | data = new chatModels.DelSuperChatMsg(data)
207 | this.msgHandler.onDelSuperChat(data)
208 | break
209 | }
210 | case COMMAND_UPDATE_TRANSLATION: {
211 | data = new chatModels.UpdateTranslationMsg({
212 | id: data[0],
213 | translation: data[1]
214 | })
215 | this.msgHandler.onUpdateTranslation(data)
216 | break
217 | }
218 | case COMMAND_FATAL_ERROR: {
219 | this.stop()
220 | let error = new chatModels.ChatClientFatalError(data.type, data.msg)
221 | this.msgHandler.onFatalError(error)
222 | break
223 | }
224 | }
225 |
226 | // 至少成功处理1条消息
227 | if (cmd !== COMMAND_FATAL_ERROR) {
228 | this.retryCount = 0
229 | }
230 | }
231 | }
232 |
--------------------------------------------------------------------------------
/frontend/src/api/chat/index.js:
--------------------------------------------------------------------------------
1 | import MD5 from 'crypto-js/md5'
2 |
3 | import { apiClient as axios } from '@/api/base'
4 |
5 | export function getDefaultMsgHandler() {
6 | let dummyFunc = () => {}
7 | return {
8 | onAddText: dummyFunc,
9 | onAddGift: dummyFunc,
10 | onAddMember: dummyFunc,
11 | onAddSuperChat: dummyFunc,
12 | onDelSuperChat: dummyFunc,
13 | onUpdateTranslation: dummyFunc,
14 |
15 | onFatalError: dummyFunc,
16 | onDebugMsg: dummyFunc,
17 | }
18 | }
19 |
20 | export const DEFAULT_AVATAR_URL = '//static.hdslb.com/images/member/noface.gif'
21 |
22 | export function processAvatarUrl(avatarUrl) {
23 | // 去掉协议,兼容HTTP、HTTPS
24 | let m = avatarUrl.match(/(?:https?:)?(.*)/)
25 | if (m) {
26 | avatarUrl = m[1]
27 | }
28 | return avatarUrl
29 | }
30 |
31 | export async function getAvatarUrl(uid, username) {
32 | if (uid === 0) {
33 | return getDefaultAvatarUrl(uid, username)
34 | }
35 |
36 | let res
37 | try {
38 | res = (await axios.get('/api/avatar_url', { params: {
39 | uid: uid,
40 | username: username
41 | } })).data
42 | } catch {
43 | return getDefaultAvatarUrl(uid, username)
44 | }
45 | return res.avatarUrl
46 | }
47 |
48 | export function getDefaultAvatarUrl(uid, username) {
49 | let strToHash
50 | if (uid !== 0) {
51 | strToHash = uid.toString()
52 | } else if (username !== '') {
53 | strToHash = username
54 | } else {
55 | return DEFAULT_AVATAR_URL
56 | }
57 | let idHash = MD5(strToHash).toString()
58 | return `//cravatar.cn/avatar/${idHash}?s=256&d=robohash&f=y`
59 | }
60 |
61 | export async function getTextEmoticons() {
62 | let res
63 | try {
64 | res = (await axios.get('/api/text_emoticon_mappings')).data
65 | } catch {
66 | return []
67 | }
68 | return res.textEmoticons
69 | }
70 |
71 | // 开放平台接口不会发送是否是礼物弹幕,只能用内容判断了
72 | const GIFT_DANMAKU_CONTENTS = new Set([
73 | // 红包
74 | '老板大气!点点红包抽礼物',
75 | '老板大气!点点红包抽礼物!',
76 | '点点红包,关注主播抽礼物~',
77 | '喜欢主播加关注,点点红包抽礼物',
78 | '红包抽礼物,开启今日好运!',
79 | '中奖喷雾!中奖喷雾!',
80 | // 节奏风暴
81 | '前方高能预警,注意这不是演习',
82 | '我从未见过如此厚颜无耻之人',
83 | '那万一赢了呢',
84 | '你们城里人真会玩',
85 | '左舷弹幕太薄了',
86 | '要优雅,不要污',
87 | '我选择狗带',
88 | '可爱即正义~~',
89 | '糟了,是心动的感觉!',
90 | '这个直播间已经被我们承包了!',
91 | '妈妈问我为什么跪着看直播 w(゚Д゚)w',
92 | '你们对力量一无所知~( ̄▽ ̄)~',
93 | // 好像花式夸夸还有,不想花钱收集内容了
94 | ])
95 |
96 | export function isGiftDanmakuByContent(content) {
97 | return GIFT_DANMAKU_CONTENTS.has(content)
98 | }
99 |
--------------------------------------------------------------------------------
/frontend/src/api/chat/models.js:
--------------------------------------------------------------------------------
1 | import { getUuid4Hex } from '@/utils'
2 | import * as constants from '@/components/ChatRenderer/constants'
3 | import * as chat from '.'
4 |
5 | export class AddTextMsg {
6 | constructor({
7 | avatarUrl = chat.DEFAULT_AVATAR_URL,
8 | timestamp = new Date().getTime() / 1000,
9 | authorName = '',
10 | authorType = constants.AUTHOR_TYPE_NORMAL,
11 | content = '',
12 | privilegeType = 0,
13 | isGiftDanmaku = false,
14 | authorLevel = 1,
15 | isNewbie = false,
16 | isMobileVerified = true,
17 | medalLevel = 0,
18 | id = getUuid4Hex(),
19 | translation = '',
20 | emoticon = null,
21 | // 给模板用的字段
22 | uid = '',
23 | medalName = '',
24 | } = {}) {
25 | this.avatarUrl = avatarUrl
26 | this.timestamp = timestamp
27 | this.authorName = authorName
28 | this.authorType = authorType
29 | this.content = content
30 | this.privilegeType = privilegeType
31 | this.isGiftDanmaku = isGiftDanmaku
32 | this.authorLevel = authorLevel
33 | this.isNewbie = isNewbie
34 | this.isMobileVerified = isMobileVerified
35 | this.medalLevel = medalLevel
36 | this.id = id
37 | this.translation = translation
38 | this.emoticon = emoticon
39 | // 给模板用的字段
40 | this.uid = uid
41 | this.medalName = medalName
42 | }
43 | }
44 |
45 | export class AddGiftMsg {
46 | constructor({
47 | id = getUuid4Hex(),
48 | avatarUrl = chat.DEFAULT_AVATAR_URL,
49 | timestamp = new Date().getTime() / 1000,
50 | authorName = '',
51 | totalCoin = 0,
52 | totalFreeCoin = 0,
53 | giftName = '',
54 | num = 1,
55 | // 给模板用的字段
56 | giftId = 0,
57 | giftIconUrl = '',
58 | uid = '',
59 | privilegeType = 0,
60 | medalLevel = 0,
61 | medalName = '',
62 | } = {}) {
63 | this.id = id
64 | this.avatarUrl = avatarUrl
65 | this.timestamp = timestamp
66 | this.authorName = authorName
67 | this.totalCoin = totalCoin
68 | this.totalFreeCoin = totalFreeCoin
69 | this.giftName = giftName
70 | this.num = num
71 | // 给模板用的字段
72 | this.giftId = giftId
73 | this.giftIconUrl = giftIconUrl
74 | this.uid = uid
75 | this.privilegeType = privilegeType
76 | this.medalLevel = medalLevel
77 | this.medalName = medalName
78 | }
79 | }
80 |
81 | export class AddMemberMsg {
82 | constructor({
83 | id = getUuid4Hex(),
84 | avatarUrl = chat.DEFAULT_AVATAR_URL,
85 | timestamp = new Date().getTime() / 1000,
86 | authorName = '',
87 | privilegeType = 1,
88 | // 给模板用的字段
89 | num = 1,
90 | unit = '月',
91 | total_coin = 0,
92 | uid = '',
93 | medalLevel = 0,
94 | medalName = '',
95 | } = {}) {
96 | this.id = id
97 | this.avatarUrl = avatarUrl
98 | this.timestamp = timestamp
99 | this.authorName = authorName
100 | this.privilegeType = privilegeType
101 | // 给模板用的字段
102 | this.num = num
103 | this.unit = unit
104 | this.totalCoin = total_coin
105 | this.uid = uid
106 | this.medalLevel = medalLevel
107 | this.medalName = medalName
108 | }
109 | }
110 |
111 | export class AddSuperChatMsg {
112 | constructor({
113 | id = getUuid4Hex(),
114 | avatarUrl = chat.DEFAULT_AVATAR_URL,
115 | timestamp = new Date().getTime() / 1000,
116 | authorName = '',
117 | price = 0,
118 | content = '',
119 | translation = '',
120 | // 给模板用的字段
121 | uid = '',
122 | privilegeType = 0,
123 | medalLevel = 0,
124 | medalName = '',
125 | } = {}) {
126 | this.id = id
127 | this.avatarUrl = avatarUrl
128 | this.timestamp = timestamp
129 | this.authorName = authorName
130 | this.price = price
131 | this.content = content
132 | this.translation = translation
133 | // 给模板用的字段
134 | this.uid = uid
135 | this.privilegeType = privilegeType
136 | this.medalLevel = medalLevel
137 | this.medalName = medalName
138 | }
139 | }
140 |
141 | export class DelSuperChatMsg {
142 | constructor({
143 | ids = [],
144 | } = {}) {
145 | this.ids = ids
146 | }
147 | }
148 |
149 | export class UpdateTranslationMsg {
150 | constructor({
151 | id = getUuid4Hex(),
152 | translation = '',
153 | } = {}) {
154 | this.id = id
155 | this.translation = translation
156 | }
157 | }
158 |
159 | export const FATAL_ERROR_TYPE_AUTH_CODE_ERROR = 1
160 | export const FATAL_ERROR_TYPE_TOO_MANY_RETRIES = 2
161 | export const FATAL_ERROR_TYPE_TOO_MANY_CONNECTIONS = 3
162 |
163 | export class ChatClientFatalError extends Error {
164 | constructor(type, message) {
165 | super(message)
166 | this.type = type
167 | }
168 | }
169 |
170 | export class DebugMsg {
171 | constructor({
172 | content = '',
173 | } = {}) {
174 | this.content = content
175 | }
176 | }
177 |
--------------------------------------------------------------------------------
/frontend/src/api/chatConfig.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 |
3 | import { mergeConfig } from '@/utils'
4 |
5 | export const DEFAULT_CONFIG = {
6 | minGiftPrice: 0.1,
7 | showDanmaku: true,
8 | showGift: true,
9 | showGiftName: false,
10 | mergeSimilarDanmaku: false,
11 | mergeGift: true,
12 | maxNumber: 60,
13 |
14 | blockGiftDanmaku: true,
15 | blockLevel: 0,
16 | blockNewbie: false,
17 | blockNotMobileVerified: false,
18 | blockKeywords: '',
19 | blockUsers: '',
20 | blockMedalLevel: 0,
21 |
22 | showDebugMessages: false,
23 | relayMessagesByServer: false,
24 | autoTranslate: false,
25 | giftUsernamePronunciation: '',
26 | importPresetCss: false,
27 |
28 | emoticons: [], // [{ keyword: '', url: '' }, ...]
29 |
30 | templateUrl: '',
31 | }
32 |
33 | export function deepCloneDefaultConfig() {
34 | return _.cloneDeep(DEFAULT_CONFIG)
35 | }
36 |
37 | export function setLocalConfig(config) {
38 | config = mergeConfig(config, DEFAULT_CONFIG)
39 | window.localStorage.config = JSON.stringify(config)
40 | }
41 |
42 | export function getLocalConfig() {
43 | try {
44 | let config = JSON.parse(window.localStorage.config)
45 | config = mergeConfig(config, deepCloneDefaultConfig())
46 | sanitizeConfig(config)
47 | return config
48 | } catch {
49 | let config = deepCloneDefaultConfig()
50 | // 新用户默认开启调试消息,免得总有人问
51 | config.showDebugMessages = true
52 | return config
53 | }
54 | }
55 |
56 | export function sanitizeConfig(config) {
57 | let newEmoticons = []
58 | if (config.emoticons instanceof Array) {
59 | for (let emoticon of config.emoticons) {
60 | try {
61 | let newEmoticon = {
62 | keyword: emoticon.keyword,
63 | url: emoticon.url
64 | }
65 | if ((typeof newEmoticon.keyword !== 'string') || (typeof newEmoticon.url !== 'string')) {
66 | continue
67 | }
68 | newEmoticons.push(newEmoticon)
69 | } catch {
70 | continue
71 | }
72 | }
73 | }
74 | config.emoticons = newEmoticons
75 | }
76 |
--------------------------------------------------------------------------------
/frontend/src/api/main.js:
--------------------------------------------------------------------------------
1 | import { apiClient as axios } from './base'
2 |
3 | export async function getServerInfo() {
4 | return (await axios.get('/api/server_info')).data
5 | }
6 |
7 | export async function uploadEmoticon(file) {
8 | let body = new FormData()
9 | body.set('file', file)
10 | return (await axios.post('/api/emoticon', body)).data
11 | }
12 |
13 | export async function getTemplates() {
14 | return (await axios.get('/api/templates')).data
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/src/api/plugins.js:
--------------------------------------------------------------------------------
1 | import { apiClient as axios } from './base'
2 |
3 | export async function getPlugins() {
4 | return (await axios.get('/api/plugin/plugins')).data
5 | }
6 |
7 | export async function setEnabled(pluginId, enabled) {
8 | return (await axios.post('/api/plugin/enable_plugin', {
9 | pluginId,
10 | enabled
11 | })).data
12 | }
13 |
14 | export async function openAdminUi(pluginId) {
15 | return (await axios.post('/api/plugin/open_admin_ui', { pluginId })).data
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/src/assets/css/youtube/yt-icon.css:
--------------------------------------------------------------------------------
1 | @layer yt {
2 |
3 | canvas.yt-icon, caption.yt-icon, center.yt-icon, cite.yt-icon, code.yt-icon, dd.yt-icon, del.yt-icon, dfn.yt-icon, div.yt-icon, dl.yt-icon, dt.yt-icon, em.yt-icon, embed.yt-icon, fieldset.yt-icon, font.yt-icon, form.yt-icon, h1.yt-icon, h2.yt-icon, h3.yt-icon, h4.yt-icon, h5.yt-icon, h6.yt-icon, hr.yt-icon, i.yt-icon, iframe.yt-icon, img.yt-icon, ins.yt-icon, kbd.yt-icon, label.yt-icon, legend.yt-icon, li.yt-icon, menu.yt-icon, object.yt-icon, ol.yt-icon, p.yt-icon, pre.yt-icon, q.yt-icon, s.yt-icon, samp.yt-icon, small.yt-icon, span.yt-icon, strike.yt-icon, strong.yt-icon, sub.yt-icon, sup.yt-icon, table.yt-icon, tbody.yt-icon, td.yt-icon, tfoot.yt-icon, th.yt-icon, thead.yt-icon, tr.yt-icon, tt.yt-icon, u.yt-icon, ul.yt-icon, var.yt-icon {
4 | margin: 0;
5 | padding: 0;
6 | border: 0;
7 | background: transparent;
8 | }
9 |
10 | .yt-icon[hidden] {
11 | display: none !important;
12 | }
13 |
14 | yt-icon, .yt-icon-container.yt-icon {
15 | display: inline-flex;
16 | -ms-flex-align: center;
17 | -webkit-align-items: center;
18 | align-items: center;
19 | -ms-flex-pack: center;
20 | -webkit-justify-content: center;
21 | justify-content: center;
22 | position: relative;
23 | vertical-align: middle;
24 | fill: currentcolor;
25 | stroke: none;
26 | width: var(--iron-icon-width, 24px);
27 | height: var(--iron-icon-height, 24px);
28 | }
29 |
30 | yt-icon.external-container {
31 | display: none !important;
32 | }
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/frontend/src/assets/css/youtube/yt-img-shadow.css:
--------------------------------------------------------------------------------
1 | @layer yt {
2 |
3 | canvas.yt-img-shadow, caption.yt-img-shadow, center.yt-img-shadow, cite.yt-img-shadow, code.yt-img-shadow, dd.yt-img-shadow, del.yt-img-shadow, dfn.yt-img-shadow, div.yt-img-shadow, dl.yt-img-shadow, dt.yt-img-shadow, em.yt-img-shadow, embed.yt-img-shadow, fieldset.yt-img-shadow, font.yt-img-shadow, form.yt-img-shadow, h1.yt-img-shadow, h2.yt-img-shadow, h3.yt-img-shadow, h4.yt-img-shadow, h5.yt-img-shadow, h6.yt-img-shadow, hr.yt-img-shadow, i.yt-img-shadow, iframe.yt-img-shadow, img.yt-img-shadow, ins.yt-img-shadow, kbd.yt-img-shadow, label.yt-img-shadow, legend.yt-img-shadow, li.yt-img-shadow, menu.yt-img-shadow, object.yt-img-shadow, ol.yt-img-shadow, p.yt-img-shadow, pre.yt-img-shadow, q.yt-img-shadow, s.yt-img-shadow, samp.yt-img-shadow, small.yt-img-shadow, span.yt-img-shadow, strike.yt-img-shadow, strong.yt-img-shadow, sub.yt-img-shadow, sup.yt-img-shadow, table.yt-img-shadow, tbody.yt-img-shadow, td.yt-img-shadow, tfoot.yt-img-shadow, th.yt-img-shadow, thead.yt-img-shadow, tr.yt-img-shadow, tt.yt-img-shadow, u.yt-img-shadow, ul.yt-img-shadow, var.yt-img-shadow {
4 | margin: 0;
5 | padding: 0;
6 | border: 0;
7 | background: transparent;
8 | }
9 |
10 | .yt-img-shadow[hidden] {
11 | display: none !important;
12 | }
13 |
14 | yt-img-shadow {
15 | display: inline-block;
16 | opacity: 0;
17 | transition: opacity 0.2s;
18 | -ms-flex: none;
19 | -webkit-flex: none;
20 | flex: none;
21 | }
22 |
23 | yt-img-shadow.no-transition {
24 | opacity: 1;
25 | transition: none;
26 | }
27 |
28 | yt-img-shadow.with-placeholder {
29 | background-color: transparent;
30 | min-height: unset;
31 | min-width: unset;
32 | }
33 |
34 | yt-img-shadow[loaded] {
35 | opacity: 1;
36 | }
37 |
38 | yt-img-shadow.empty img.yt-img-shadow {
39 | visibility: hidden;
40 | }
41 |
42 | yt-img-shadow[object-fit="FILL"] img.yt-img-shadow, yt-img-shadow[fit] img.yt-img-shadow {
43 | width: 100%;
44 | height: 100%;
45 | }
46 |
47 | yt-img-shadow[object-fit="COVER"] img.yt-img-shadow {
48 | width: 100%;
49 | height: 100%;
50 | object-fit: cover;
51 | }
52 |
53 | yt-img-shadow[object-fit="CONTAIN"] img.yt-img-shadow {
54 | width: 100%;
55 | height: 100%;
56 | object-fit: contain;
57 | }
58 |
59 | yt-img-shadow[object-position="LEFT"] img.yt-img-shadow {
60 | object-position: left;
61 | }
62 |
63 | img.yt-img-shadow {
64 | display: block;
65 | margin-left: auto;
66 | margin-right: auto;
67 | max-height: none;
68 | max-width: 100%;
69 | border-radius: none;
70 | }
71 |
72 | }
73 |
--------------------------------------------------------------------------------
/frontend/src/assets/css/youtube/yt-live-chat-author-badge-renderer.css:
--------------------------------------------------------------------------------
1 | @layer yt {
2 |
3 | canvas.yt-live-chat-author-badge-renderer, caption.yt-live-chat-author-badge-renderer, center.yt-live-chat-author-badge-renderer, cite.yt-live-chat-author-badge-renderer, code.yt-live-chat-author-badge-renderer, dd.yt-live-chat-author-badge-renderer, del.yt-live-chat-author-badge-renderer, dfn.yt-live-chat-author-badge-renderer, div.yt-live-chat-author-badge-renderer, dl.yt-live-chat-author-badge-renderer, dt.yt-live-chat-author-badge-renderer, em.yt-live-chat-author-badge-renderer, embed.yt-live-chat-author-badge-renderer, fieldset.yt-live-chat-author-badge-renderer, font.yt-live-chat-author-badge-renderer, form.yt-live-chat-author-badge-renderer, h1.yt-live-chat-author-badge-renderer, h2.yt-live-chat-author-badge-renderer, h3.yt-live-chat-author-badge-renderer, h4.yt-live-chat-author-badge-renderer, h5.yt-live-chat-author-badge-renderer, h6.yt-live-chat-author-badge-renderer, hr.yt-live-chat-author-badge-renderer, i.yt-live-chat-author-badge-renderer, iframe.yt-live-chat-author-badge-renderer, img.yt-live-chat-author-badge-renderer, ins.yt-live-chat-author-badge-renderer, kbd.yt-live-chat-author-badge-renderer, label.yt-live-chat-author-badge-renderer, legend.yt-live-chat-author-badge-renderer, li.yt-live-chat-author-badge-renderer, menu.yt-live-chat-author-badge-renderer, object.yt-live-chat-author-badge-renderer, ol.yt-live-chat-author-badge-renderer, p.yt-live-chat-author-badge-renderer, pre.yt-live-chat-author-badge-renderer, q.yt-live-chat-author-badge-renderer, s.yt-live-chat-author-badge-renderer, samp.yt-live-chat-author-badge-renderer, small.yt-live-chat-author-badge-renderer, span.yt-live-chat-author-badge-renderer, strike.yt-live-chat-author-badge-renderer, strong.yt-live-chat-author-badge-renderer, sub.yt-live-chat-author-badge-renderer, sup.yt-live-chat-author-badge-renderer, table.yt-live-chat-author-badge-renderer, tbody.yt-live-chat-author-badge-renderer, td.yt-live-chat-author-badge-renderer, tfoot.yt-live-chat-author-badge-renderer, th.yt-live-chat-author-badge-renderer, thead.yt-live-chat-author-badge-renderer, tr.yt-live-chat-author-badge-renderer, tt.yt-live-chat-author-badge-renderer, u.yt-live-chat-author-badge-renderer, ul.yt-live-chat-author-badge-renderer, var.yt-live-chat-author-badge-renderer {
4 | margin: 0;
5 | padding: 0;
6 | border: 0;
7 | background: transparent;
8 | }
9 |
10 | .yt-live-chat-author-badge-renderer[hidden] {
11 | display: none !important;
12 | }
13 |
14 | yt-live-chat-author-badge-renderer {
15 | display: inline-block;
16 | }
17 |
18 | yt-live-chat-author-badge-renderer[type='moderator'] {
19 | color: var(--yt-live-chat-moderator-color, #5e84f1);
20 | }
21 |
22 | yt-live-chat-author-badge-renderer[type='owner'] {
23 | color: var(--yt-live-chat-owner-color, #ffd600);
24 | }
25 |
26 | yt-live-chat-author-badge-renderer[type='member'] {
27 | color: var(--yt-live-chat-sponsor-color, #107516);
28 | }
29 |
30 | yt-live-chat-author-badge-renderer[type='verified'] {
31 | color: #999;
32 | }
33 |
34 | img.yt-live-chat-author-badge-renderer, yt-icon.yt-live-chat-author-badge-renderer {
35 | display: block;
36 | width: 16px;
37 | height: 16px;
38 | }
39 |
40 | }
41 |
--------------------------------------------------------------------------------
/frontend/src/assets/css/youtube/yt-live-chat-author-chip.css:
--------------------------------------------------------------------------------
1 | @layer yt {
2 |
3 | canvas.yt-live-chat-author-chip, caption.yt-live-chat-author-chip, center.yt-live-chat-author-chip, cite.yt-live-chat-author-chip, code.yt-live-chat-author-chip, dd.yt-live-chat-author-chip, del.yt-live-chat-author-chip, dfn.yt-live-chat-author-chip, div.yt-live-chat-author-chip, dl.yt-live-chat-author-chip, dt.yt-live-chat-author-chip, em.yt-live-chat-author-chip, embed.yt-live-chat-author-chip, fieldset.yt-live-chat-author-chip, font.yt-live-chat-author-chip, form.yt-live-chat-author-chip, h1.yt-live-chat-author-chip, h2.yt-live-chat-author-chip, h3.yt-live-chat-author-chip, h4.yt-live-chat-author-chip, h5.yt-live-chat-author-chip, h6.yt-live-chat-author-chip, hr.yt-live-chat-author-chip, i.yt-live-chat-author-chip, iframe.yt-live-chat-author-chip, img.yt-live-chat-author-chip, ins.yt-live-chat-author-chip, kbd.yt-live-chat-author-chip, label.yt-live-chat-author-chip, legend.yt-live-chat-author-chip, li.yt-live-chat-author-chip, menu.yt-live-chat-author-chip, object.yt-live-chat-author-chip, ol.yt-live-chat-author-chip, p.yt-live-chat-author-chip, pre.yt-live-chat-author-chip, q.yt-live-chat-author-chip, s.yt-live-chat-author-chip, samp.yt-live-chat-author-chip, small.yt-live-chat-author-chip, span.yt-live-chat-author-chip, strike.yt-live-chat-author-chip, strong.yt-live-chat-author-chip, sub.yt-live-chat-author-chip, sup.yt-live-chat-author-chip, table.yt-live-chat-author-chip, tbody.yt-live-chat-author-chip, td.yt-live-chat-author-chip, tfoot.yt-live-chat-author-chip, th.yt-live-chat-author-chip, thead.yt-live-chat-author-chip, tr.yt-live-chat-author-chip, tt.yt-live-chat-author-chip, u.yt-live-chat-author-chip, ul.yt-live-chat-author-chip, var.yt-live-chat-author-chip {
4 | margin: 0;
5 | padding: 0;
6 | border: 0;
7 | background: transparent;
8 | }
9 |
10 | .yt-live-chat-author-chip[hidden] {
11 | display: none !important;
12 | }
13 |
14 | yt-live-chat-author-chip {
15 | display: inline-flex;
16 | -ms-flex-align: baseline;
17 | -webkit-align-items: baseline;
18 | align-items: baseline;
19 | }
20 |
21 | #author-name.yt-live-chat-author-chip {
22 | box-sizing: border-box;
23 | border-radius: 2px;
24 | color: var(--yt-live-chat-secondary-text-color);
25 | font-weight: 500;
26 | }
27 |
28 | yt-live-chat-author-chip[is-highlighted] #author-name.yt-live-chat-author-chip {
29 | padding: 2px 4px;
30 | color: var(--yt-live-chat-author-chip-verified-text-color);
31 | background-color: var(--yt-live-chat-author-chip-verified-background-color);
32 | }
33 |
34 | #author-name.yt-live-chat-author-chip[type='moderator'] {
35 | color: var(--yt-live-chat-moderator-color);
36 | }
37 |
38 | yt-live-chat-author-chip[is-highlighted] #author-name.yt-live-chat-author-chip[type='owner'], #author-name.yt-live-chat-author-chip[type='owner'] {
39 | background-color: #ffd600;
40 | color: var(--yt-live-chat-author-chip-owner-text-color);
41 | }
42 |
43 | #author-name.yt-live-chat-author-chip[type='member'] {
44 | color: var(--yt-live-chat-sponsor-color);
45 | }
46 |
47 | #chip-badges.yt-live-chat-author-chip:empty {
48 | display: none;
49 | }
50 |
51 | yt-live-chat-author-chip[is-highlighted] #chat-badges.yt-live-chat-author-chip:not(:empty) {
52 | margin-left: 1px;
53 | }
54 |
55 | yt-live-chat-author-badge-renderer.yt-live-chat-author-chip {
56 | margin: 0 0 0 2px;
57 | vertical-align: sub;
58 | }
59 |
60 | yt-live-chat-author-chip[is-highlighted] #chip-badges.yt-live-chat-author-chip yt-live-chat-author-badge-renderer.yt-live-chat-author-chip {
61 | color: inherit;
62 | }
63 |
64 | #chip-badges.yt-live-chat-author-chip yt-live-chat-author-badge-renderer.yt-live-chat-author-chip:last-of-type {
65 | margin-right: -2px;
66 | }
67 |
68 | }
69 |
--------------------------------------------------------------------------------
/frontend/src/assets/css/youtube/yt-live-chat-item-list-renderer.css:
--------------------------------------------------------------------------------
1 | @layer yt {
2 |
3 | canvas.yt-live-chat-item-list-renderer, caption.yt-live-chat-item-list-renderer, center.yt-live-chat-item-list-renderer, cite.yt-live-chat-item-list-renderer, code.yt-live-chat-item-list-renderer, dd.yt-live-chat-item-list-renderer, del.yt-live-chat-item-list-renderer, dfn.yt-live-chat-item-list-renderer, div.yt-live-chat-item-list-renderer, dl.yt-live-chat-item-list-renderer, dt.yt-live-chat-item-list-renderer, em.yt-live-chat-item-list-renderer, embed.yt-live-chat-item-list-renderer, fieldset.yt-live-chat-item-list-renderer, font.yt-live-chat-item-list-renderer, form.yt-live-chat-item-list-renderer, h1.yt-live-chat-item-list-renderer, h2.yt-live-chat-item-list-renderer, h3.yt-live-chat-item-list-renderer, h4.yt-live-chat-item-list-renderer, h5.yt-live-chat-item-list-renderer, h6.yt-live-chat-item-list-renderer, hr.yt-live-chat-item-list-renderer, i.yt-live-chat-item-list-renderer, iframe.yt-live-chat-item-list-renderer, img.yt-live-chat-item-list-renderer, ins.yt-live-chat-item-list-renderer, kbd.yt-live-chat-item-list-renderer, label.yt-live-chat-item-list-renderer, legend.yt-live-chat-item-list-renderer, li.yt-live-chat-item-list-renderer, menu.yt-live-chat-item-list-renderer, object.yt-live-chat-item-list-renderer, ol.yt-live-chat-item-list-renderer, p.yt-live-chat-item-list-renderer, pre.yt-live-chat-item-list-renderer, q.yt-live-chat-item-list-renderer, s.yt-live-chat-item-list-renderer, samp.yt-live-chat-item-list-renderer, small.yt-live-chat-item-list-renderer, span.yt-live-chat-item-list-renderer, strike.yt-live-chat-item-list-renderer, strong.yt-live-chat-item-list-renderer, sub.yt-live-chat-item-list-renderer, sup.yt-live-chat-item-list-renderer, table.yt-live-chat-item-list-renderer, tbody.yt-live-chat-item-list-renderer, td.yt-live-chat-item-list-renderer, tfoot.yt-live-chat-item-list-renderer, th.yt-live-chat-item-list-renderer, thead.yt-live-chat-item-list-renderer, tr.yt-live-chat-item-list-renderer, tt.yt-live-chat-item-list-renderer, u.yt-live-chat-item-list-renderer, ul.yt-live-chat-item-list-renderer, var.yt-live-chat-item-list-renderer {
4 | margin: 0;
5 | padding: 0;
6 | border: 0;
7 | background: transparent;
8 | }
9 |
10 | .yt-live-chat-item-list-renderer[hidden] {
11 | display: none !important;
12 | }
13 |
14 | yt-live-chat-item-list-renderer {
15 | position: relative;
16 | display: block;
17 | overflow: hidden;
18 | z-index: 0;
19 | }
20 |
21 | yt-live-chat-item-list-renderer[moderation-mode-enabled] {
22 | --yt-live-chat-item-with-inline-actions-context-menu-display: none;
23 | --yt-live-chat-inline-action-button-container-display: flex;
24 | }
25 |
26 | #contents.yt-live-chat-item-list-renderer {
27 | position: absolute;
28 | top: 0;
29 | right: 0;
30 | bottom: 0;
31 | left: 0;
32 | display: flex;
33 | -ms-flex-direction: column;
34 | -webkit-flex-direction: column;
35 | flex-direction: column;
36 | }
37 |
38 | #empty-state-message.yt-live-chat-item-list-renderer {
39 | position: absolute;
40 | top: 0;
41 | right: 0;
42 | bottom: 0;
43 | left: 0;
44 | display: flex;
45 | -ms-flex-direction: column;
46 | -webkit-flex-direction: column;
47 | flex-direction: column;
48 | -ms-flex-pack: center;
49 | -webkit-justify-content: center;
50 | justify-content: center;
51 | }
52 |
53 | #empty-state-message.yt-live-chat-item-list-renderer>yt-live-chat-message-renderer.yt-live-chat-item-list-renderer {
54 | color: var(--yt-live-chat-tertiary-text-color);
55 | background: transparent;
56 | font-size: 18px;
57 | --yt-live-chat-message-renderer-text-align: center;
58 | }
59 |
60 | yt-icon-button.yt-live-chat-item-list-renderer {
61 | background-color: #2196f3;
62 | border-radius: 999px;
63 | bottom: 0;
64 | color: #fff;
65 | cursor: pointer;
66 | width: 32px;
67 | height: 32px;
68 | margin: 0 calc(50% - 16px) 8px calc(50% - 16px);
69 | padding: 4px;
70 | position: absolute;
71 | transition-property: bottom;
72 | transition-timing-function: cubic-bezier(0.0, 0.0, 0.2, 1);
73 | transition-duration: 0.15s;
74 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2);
75 | }
76 |
77 | yt-icon-button.yt-live-chat-item-list-renderer[disabled] {
78 | bottom: -42px;
79 | color: #fff;
80 | transition-timing-function: cubic-bezier(0.4, 0.0, 1, 1);
81 | }
82 |
83 | #item-scroller.yt-live-chat-item-list-renderer {
84 | -ms-flex: 1 1 0.000000001px;
85 | -webkit-flex: 1;
86 | flex: 1;
87 | -webkit-flex-basis: 0.000000001px;
88 | flex-basis: 0.000000001px;
89 | overflow-x: hidden;
90 | overflow-y: hidden;
91 | padding-right: var(--scrollbar-width);
92 | }
93 |
94 | yt-live-chat-item-list-renderer[allow-scroll] #item-scroller.yt-live-chat-item-list-renderer {
95 | overflow-y: scroll;
96 | padding-right: 0;
97 | }
98 |
99 | #item-offset.yt-live-chat-item-list-renderer {
100 | position: relative;
101 | }
102 |
103 | #item-scroller.animated.yt-live-chat-item-list-renderer #item-offset.yt-live-chat-item-list-renderer {
104 | overflow: hidden;
105 | }
106 |
107 | #items.yt-live-chat-item-list-renderer {
108 | -ms-flex: 1 1 0.000000001px;
109 | -webkit-flex: 1;
110 | flex: 1;
111 | -webkit-flex-basis: 0.000000001px;
112 | flex-basis: 0.000000001px;
113 | padding: var(--yt-live-chat-item-list-renderer-padding, 4px 0);
114 | }
115 |
116 | #items.yt-live-chat-item-list-renderer>*.yt-live-chat-item-list-renderer:not(:first-child) {
117 | border-top: var(--yt-live-chat-item-list-item-border, none);
118 | }
119 |
120 | #item-scroller.animated.yt-live-chat-item-list-renderer #items.yt-live-chat-item-list-renderer {
121 | bottom: 0;
122 | left: 0;
123 | position: absolute;
124 | right: 0;
125 | transform: translateY(0);
126 | }
127 |
128 | #docked-messages.yt-live-chat-item-list-renderer {
129 | z-index: 1;
130 | position: absolute;
131 | left: 0;
132 | right: 0;
133 | top: 0;
134 | }
135 |
136 | yt-live-chat-paid-sticker-renderer.yt-live-chat-item-list-renderer {
137 | padding: 4px 24px;
138 | }
139 |
140 | yt-live-chat-paid-sticker-renderer.yt-live-chat-item-list-renderer[dashboard-money-feed] {
141 | padding: 8px 16px;
142 | }
143 |
144 | }
145 |
--------------------------------------------------------------------------------
/frontend/src/assets/css/youtube/yt-live-chat-ticker-renderer.css:
--------------------------------------------------------------------------------
1 | @layer yt {
2 |
3 | canvas.yt-live-chat-ticker-renderer, caption.yt-live-chat-ticker-renderer, center.yt-live-chat-ticker-renderer, cite.yt-live-chat-ticker-renderer, code.yt-live-chat-ticker-renderer, dd.yt-live-chat-ticker-renderer, del.yt-live-chat-ticker-renderer, dfn.yt-live-chat-ticker-renderer, div.yt-live-chat-ticker-renderer, dl.yt-live-chat-ticker-renderer, dt.yt-live-chat-ticker-renderer, em.yt-live-chat-ticker-renderer, embed.yt-live-chat-ticker-renderer, fieldset.yt-live-chat-ticker-renderer, font.yt-live-chat-ticker-renderer, form.yt-live-chat-ticker-renderer, h1.yt-live-chat-ticker-renderer, h2.yt-live-chat-ticker-renderer, h3.yt-live-chat-ticker-renderer, h4.yt-live-chat-ticker-renderer, h5.yt-live-chat-ticker-renderer, h6.yt-live-chat-ticker-renderer, hr.yt-live-chat-ticker-renderer, i.yt-live-chat-ticker-renderer, iframe.yt-live-chat-ticker-renderer, img.yt-live-chat-ticker-renderer, ins.yt-live-chat-ticker-renderer, kbd.yt-live-chat-ticker-renderer, label.yt-live-chat-ticker-renderer, legend.yt-live-chat-ticker-renderer, li.yt-live-chat-ticker-renderer, menu.yt-live-chat-ticker-renderer, object.yt-live-chat-ticker-renderer, ol.yt-live-chat-ticker-renderer, p.yt-live-chat-ticker-renderer, pre.yt-live-chat-ticker-renderer, q.yt-live-chat-ticker-renderer, s.yt-live-chat-ticker-renderer, samp.yt-live-chat-ticker-renderer, small.yt-live-chat-ticker-renderer, span.yt-live-chat-ticker-renderer, strike.yt-live-chat-ticker-renderer, strong.yt-live-chat-ticker-renderer, sub.yt-live-chat-ticker-renderer, sup.yt-live-chat-ticker-renderer, table.yt-live-chat-ticker-renderer, tbody.yt-live-chat-ticker-renderer, td.yt-live-chat-ticker-renderer, tfoot.yt-live-chat-ticker-renderer, th.yt-live-chat-ticker-renderer, thead.yt-live-chat-ticker-renderer, tr.yt-live-chat-ticker-renderer, tt.yt-live-chat-ticker-renderer, u.yt-live-chat-ticker-renderer, ul.yt-live-chat-ticker-renderer, var.yt-live-chat-ticker-renderer {
4 | margin: 0;
5 | padding: 0;
6 | border: 0;
7 | background: transparent;
8 | }
9 |
10 | .yt-live-chat-ticker-renderer[hidden] {
11 | display: none !important;
12 | }
13 |
14 | yt-live-chat-ticker-renderer {
15 | display: block;
16 | background-color: var(--yt-live-chat-header-background-color);
17 | }
18 |
19 | #container.yt-live-chat-ticker-renderer {
20 | position: relative;
21 | }
22 |
23 | #items.yt-live-chat-ticker-renderer {
24 | height: 32px;
25 | /* 为了支持滚动 */
26 | /* overflow: hidden; */
27 | overflow: visible;
28 | white-space: nowrap;
29 | padding: 0 24px 8px 24px;
30 | }
31 |
32 | #items.yt-live-chat-ticker-renderer>*.yt-live-chat-ticker-renderer {
33 | margin-right: 8px;
34 | }
35 |
36 | #left-arrow-container.yt-live-chat-ticker-renderer {
37 | background: linear-gradient(to right, var(--yt-live-chat-ticker-arrow-background) 0, var(--yt-live-chat-ticker-arrow-background) 52px, transparent 60px);
38 | left: 0;
39 | padding: 0 16px 0 12px;
40 | }
41 |
42 | #right-arrow-container.yt-live-chat-ticker-renderer {
43 | background: linear-gradient(to left, var(--yt-live-chat-ticker-arrow-background) 0, var(--yt-live-chat-ticker-arrow-background) 52px, transparent 60px);
44 | right: 0;
45 | padding: 0 12px 0 16px;
46 | }
47 |
48 | #container.yt-live-chat-ticker-renderer:hover #left-arrow-container.yt-live-chat-ticker-renderer, #container.yt-live-chat-ticker-renderer:hover #right-arrow-container.yt-live-chat-ticker-renderer {
49 | opacity: 1;
50 | }
51 |
52 | #left-arrow-container.yt-live-chat-ticker-renderer, #right-arrow-container.yt-live-chat-ticker-renderer {
53 | height: 32px;
54 | opacity: 0;
55 | position: absolute;
56 | text-align: center;
57 | top: 0;
58 | transition: opacity 0.3s 0.1s;
59 | }
60 |
61 | yt-icon.yt-live-chat-ticker-renderer {
62 | background-color: #2196f3;
63 | border-radius: 999px;
64 | color: #fff;
65 | cursor: pointer;
66 | height: 24px;
67 | padding: 4px;
68 | width: 24px;
69 | }
70 |
71 | }
72 |
--------------------------------------------------------------------------------
/frontend/src/assets/img/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/frontend/src/assets/img/logo.png
--------------------------------------------------------------------------------
/frontend/src/components/ChatRenderer/AuthorBadge.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
15 |
16 |
![]()
19 |
20 |
21 |
22 |
23 |
24 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/frontend/src/components/ChatRenderer/AuthorChip.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 | {{ authorName }}
7 |
8 |
9 |
10 |
11 |
14 |
15 |
18 |
21 |
22 |
23 |
24 |
25 |
26 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/frontend/src/components/ChatRenderer/ImgShadow.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/frontend/src/components/ChatRenderer/MembershipItem.vue:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
22 |
23 |
24 |
25 |
26 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/frontend/src/components/ChatRenderer/PaidMessage.vue:
--------------------------------------------------------------------------------
1 |
2 |
13 |
30 |
31 |
32 |
33 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/frontend/src/components/ChatRenderer/TextMessage.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
{{ timeText }}
8 |
11 |
12 |
13 | {{ content.text }}
14 |
15 |
21 |
22 |
25 |
26 |
27 |
28 |
29 |
30 |
86 |
87 |
102 |
103 |
104 |
--------------------------------------------------------------------------------
/frontend/src/components/ChatRenderer/constants.js:
--------------------------------------------------------------------------------
1 | import * as i18n from '@/i18n'
2 |
3 | export const AUTHOR_TYPE_NORMAL = 0
4 | export const AUTHOR_TYPE_MEMBER = 1
5 | export const AUTHOR_TYPE_ADMIN = 2
6 | export const AUTHOR_TYPE_OWNER = 3
7 |
8 | export const AUTHOR_TYPE_TO_TEXT = [
9 | '',
10 | 'member', // 舰队
11 | 'moderator', // 房管
12 | 'owner' // 主播
13 | ]
14 |
15 | const GUARD_LEVEL_TO_TEXT_KEY = [
16 | '',
17 | 'chat.guardLevel1',
18 | 'chat.guardLevel2',
19 | 'chat.guardLevel3'
20 | ]
21 |
22 | export function getShowGuardLevelText(guardLevel) {
23 | let key = GUARD_LEVEL_TO_TEXT_KEY[guardLevel] || ''
24 | if (key === '') {
25 | return ''
26 | }
27 | return i18n.i18n.t(key)
28 | }
29 |
30 | export const MESSAGE_TYPE_TEXT = 0
31 | export const MESSAGE_TYPE_GIFT = 1
32 | export const MESSAGE_TYPE_MEMBER = 2
33 | export const MESSAGE_TYPE_SUPER_CHAT = 3
34 | export const MESSAGE_TYPE_DEL = 4
35 | export const MESSAGE_TYPE_UPDATE = 5
36 |
37 | export const CONTENT_PART_TYPE_TEXT = 0
38 | export const CONTENT_PART_TYPE_IMAGE = 1
39 |
40 | // 美元 -> 人民币 汇率
41 | const EXCHANGE_RATE = 7
42 | const PRICE_CONFIGS = [
43 | // 0 淡蓝
44 | {
45 | price: 0,
46 | colors: {
47 | contentBg: 'rgba(153, 236, 255, 1)',
48 | headerBg: 'rgba(153, 236, 255, 1)',
49 | header: 'rgba(0,0,0,1)',
50 | authorName: 'rgba(0,0,0,0.701961)',
51 | time: 'rgba(0,0,0,0.501961)',
52 | content: 'rgba(0,0,0,1)'
53 | },
54 | pinTime: 0,
55 | priceLevel: 0,
56 | },
57 | // ¥0.01 蓝
58 | {
59 | price: 0.01,
60 | colors: {
61 | contentBg: 'rgba(30,136,229,1)',
62 | headerBg: 'rgba(21,101,192,1)',
63 | header: 'rgba(255,255,255,1)',
64 | authorName: 'rgba(255,255,255,0.701961)',
65 | time: 'rgba(255,255,255,0.501961)',
66 | content: 'rgba(255,255,255,1)'
67 | },
68 | pinTime: 0,
69 | priceLevel: 1,
70 | },
71 | // $2 浅蓝
72 | {
73 | price: 2 * EXCHANGE_RATE,
74 | colors: {
75 | contentBg: 'rgba(0,229,255,1)',
76 | headerBg: 'rgba(0,184,212,1)',
77 | header: 'rgba(0,0,0,1)',
78 | authorName: 'rgba(0,0,0,0.701961)',
79 | time: 'rgba(0,0,0,0.501961)',
80 | content: 'rgba(0,0,0,1)'
81 | },
82 | pinTime: 0,
83 | priceLevel: 2,
84 | },
85 | // $5 绿
86 | {
87 | price: 5 * EXCHANGE_RATE,
88 | colors: {
89 | contentBg: 'rgba(29,233,182,1)',
90 | headerBg: 'rgba(0,191,165,1)',
91 | header: 'rgba(0,0,0,1)',
92 | authorName: 'rgba(0,0,0,0.541176)',
93 | time: 'rgba(0,0,0,0.501961)',
94 | content: 'rgba(0,0,0,1)'
95 | },
96 | pinTime: 2,
97 | priceLevel: 3,
98 | },
99 | // $10 黄
100 | {
101 | price: 10 * EXCHANGE_RATE,
102 | colors: {
103 | contentBg: 'rgba(255,202,40,1)',
104 | headerBg: 'rgba(255,179,0,1)',
105 | header: 'rgba(0,0,0,0.87451)',
106 | authorName: 'rgba(0,0,0,0.541176)',
107 | time: 'rgba(0,0,0,0.501961)',
108 | content: 'rgba(0,0,0,0.87451)'
109 | },
110 | pinTime: 5,
111 | priceLevel: 4,
112 | },
113 | // $20 橙
114 | {
115 | price: 20 * EXCHANGE_RATE,
116 | colors: {
117 | contentBg: 'rgba(245,124,0,1)',
118 | headerBg: 'rgba(230,81,0,1)',
119 | header: 'rgba(255,255,255,0.87451)',
120 | authorName: 'rgba(255,255,255,0.701961)',
121 | time: 'rgba(255,255,255,0.501961)',
122 | content: 'rgba(255,255,255,0.87451)'
123 | },
124 | pinTime: 10,
125 | priceLevel: 5,
126 | },
127 | // $50 品红
128 | {
129 | price: 50 * EXCHANGE_RATE,
130 | colors: {
131 | contentBg: 'rgba(233,30,99,1)',
132 | headerBg: 'rgba(194,24,91,1)',
133 | header: 'rgba(255,255,255,1)',
134 | authorName: 'rgba(255,255,255,0.701961)',
135 | time: 'rgba(255,255,255,0.501961)',
136 | content: 'rgba(255,255,255,1)'
137 | },
138 | pinTime: 30,
139 | priceLevel: 6,
140 | },
141 | // $100 红
142 | {
143 | price: 100 * EXCHANGE_RATE,
144 | colors: {
145 | contentBg: 'rgba(230,33,23,1)',
146 | headerBg: 'rgba(208,0,0,1)',
147 | header: 'rgba(255,255,255,1)',
148 | authorName: 'rgba(255,255,255,0.701961)',
149 | time: 'rgba(255,255,255,0.501961)',
150 | content: 'rgba(255,255,255,1)'
151 | },
152 | pinTime: 60,
153 | priceLevel: 7,
154 | },
155 | ]
156 |
157 | export function getPriceConfig(price) {
158 | let i = 0
159 | // 根据先验知识,从小找到大通常更快结束
160 | for (; i < PRICE_CONFIGS.length - 1; i++) {
161 | let nextConfig = PRICE_CONFIGS[i + 1]
162 | if (price < nextConfig.price) {
163 | return PRICE_CONFIGS[i]
164 | }
165 | }
166 | return PRICE_CONFIGS[i]
167 | }
168 |
169 | export function getShowContent(message) {
170 | if (message.translation) {
171 | return `${message.content}(${message.translation})`
172 | }
173 | return message.content
174 | }
175 |
176 | export function getShowContentParts(message) {
177 | let contentParts = [...message.contentParts]
178 | if (message.translation) {
179 | contentParts.push({
180 | type: CONTENT_PART_TYPE_TEXT,
181 | text: `(${message.translation})`
182 | })
183 | }
184 | return contentParts
185 | }
186 |
187 | export function getGiftShowContent(message, showGiftName) {
188 | if (!showGiftName) {
189 | return ''
190 | }
191 | return i18n.i18n.t('chat.sendGift', { giftName: message.giftName, num: message.num })
192 | }
193 |
194 | export function getGiftShowNameAndNum(message) {
195 | return `${message.giftName}x${message.num}`
196 | }
197 |
198 | export function getShowAuthorName(message) {
199 | if (message.authorNamePronunciation && message.authorNamePronunciation !== message.authorName) {
200 | return `${message.authorName}(${message.authorNamePronunciation})`
201 | }
202 | return message.authorName
203 | }
204 |
--------------------------------------------------------------------------------
/frontend/src/i18n.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import VueI18n from 'vue-i18n'
3 |
4 | import zh from '@/lang/zh'
5 |
6 | let lastSetLocale = 'zh'
7 | let loadedLocales = ['zh']
8 |
9 | if (!process.env.LIB_USE_CDN) {
10 | Vue.use(VueI18n)
11 | }
12 |
13 | export async function setLocale(locale) {
14 | lastSetLocale = locale
15 | if (loadedLocales.indexOf(locale) === -1) {
16 | // eslint-disable-next-line prefer-template
17 | let langModule = await import('@/lang/' + locale)
18 | i18n.setLocaleMessage(locale, langModule.default)
19 | loadedLocales.push(locale)
20 |
21 | // 加载完成之前又调用了setLocale,这次的不生效
22 | if (locale !== lastSetLocale) {
23 | return
24 | }
25 | }
26 | window.localStorage.lang = i18n.locale = locale
27 | }
28 |
29 | export const i18n = new VueI18n({
30 | locale: 'zh',
31 | fallbackLocale: 'zh',
32 | messages: {
33 | zh
34 | }
35 | })
36 |
37 | function getDefaultLocale() {
38 | let locale = window.localStorage.lang
39 | if (!locale) {
40 | let lang = navigator.language
41 | if (lang.startsWith('zh')) {
42 | locale = 'zh'
43 | } else if (lang.startsWith('ja')) {
44 | locale = 'ja'
45 | } else {
46 | locale = 'en'
47 | }
48 | }
49 | return locale
50 | }
51 | setLocale(getDefaultLocale())
52 |
--------------------------------------------------------------------------------
/frontend/src/layout/Sidebar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 | {{ $t('sidebar.home') }}
12 |
13 |
14 | {{ $t('sidebar.stylegen') }}
15 |
16 |
17 | {{ $t('sidebar.help') }}
18 |
19 |
20 | {{ $t('sidebar.plugins') }}
21 |
22 |
23 |
24 | {{ $t('sidebar.links') }}
25 |
26 |
27 |
28 | {{ $t('sidebar.projectAddress') }}
29 |
30 |
31 |
32 |
33 | {{ $t('sidebar.discussion') }}
34 |
35 |
36 |
37 |
38 | {{ $t('sidebar.documentation') }}
39 |
40 |
41 |
42 |
43 | {{ $t('sidebar.mall') }}
44 |
45 |
46 |
47 |
48 | {{ $t('sidebar.giftRecordOfficial') }}
49 |
50 |
51 |
52 |
53 |
54 | Language
55 |
56 |
57 | {{ locale.name }}
58 |
59 |
60 |
61 |
62 |
63 |
64 |
85 |
86 |
103 |
--------------------------------------------------------------------------------
/frontend/src/layout/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
53 |
54 |
126 |
--------------------------------------------------------------------------------
/frontend/src/main.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue'
2 | import VueRouter from 'vue-router'
3 | import ElementUI from 'element-ui'
4 | if (!process.env.LIB_USE_CDN) {
5 | import('element-ui/lib/theme-chalk/index.css')
6 | }
7 |
8 | import * as apiBase from './api/base'
9 | import * as i18n from './i18n'
10 | import App from './App'
11 | import NotFound from './views/NotFound'
12 |
13 | if (!process.env.LIB_USE_CDN) {
14 | Vue.use(VueRouter)
15 | Vue.use(ElementUI)
16 | }
17 |
18 | Vue.config.ignoredElements = [
19 | /^yt-/
20 | ]
21 |
22 | const router = new VueRouter({
23 | mode: 'history',
24 | routes: [
25 | {
26 | path: '/',
27 | component: () => import('./layout'),
28 | children: [
29 | { path: '', name: 'home', component: () => import('./views/Home') },
30 | { path: 'stylegen', name: 'stylegen', component: () => import('./views/StyleGenerator') },
31 | { path: 'help', name: 'help', component: () => import('./views/Help') },
32 | { path: 'plugins', name: 'plugins', component: () => import('./views/Plugins') },
33 | ]
34 | },
35 | {
36 | path: '/room/test',
37 | name: 'test_room',
38 | component: () => import('./views/Room'),
39 | props: route => ({ strConfig: route.query })
40 | },
41 | {
42 | path: '/room/:roomKeyValue',
43 | name: 'room',
44 | component: () => import('./views/Room'),
45 | props(route) {
46 | let roomKeyType = parseInt(route.query.roomKeyType) || 1
47 | if (roomKeyType < 1 || roomKeyType > 2) {
48 | roomKeyType = 1
49 | }
50 |
51 | let roomKeyValue = route.params.roomKeyValue
52 | if (roomKeyType === 1) {
53 | roomKeyValue = parseInt(roomKeyValue) || null
54 | } else {
55 | roomKeyValue = roomKeyValue || null
56 | }
57 | return { roomKeyType, roomKeyValue, strConfig: route.query }
58 | }
59 | },
60 | { path: '*', component: NotFound }
61 | ]
62 | })
63 |
64 | await apiBase.init()
65 |
66 | new Vue({
67 | render: h => h(App),
68 | router,
69 | i18n: i18n.i18n
70 | }).$mount('#app')
71 |
--------------------------------------------------------------------------------
/frontend/src/utils/index.js:
--------------------------------------------------------------------------------
1 | export function mergeConfig(config, defaultConfig) {
2 | let res = {}
3 | for (let i in defaultConfig) {
4 | res[i] = i in config ? config[i] : defaultConfig[i]
5 | }
6 | return res
7 | }
8 |
9 | export function toBool(val) {
10 | if (typeof val === 'string') {
11 | return ['false', 'no', 'off', '0', ''].indexOf(val.toLowerCase()) === -1
12 | }
13 | return Boolean(val)
14 | }
15 |
16 | export function toInt(val, _default) {
17 | let res = parseInt(val)
18 | if (isNaN(res)) {
19 | res = _default
20 | }
21 | return res
22 | }
23 |
24 | export function toFloat(val, _default) {
25 | let res = parseFloat(val)
26 | if (isNaN(res)) {
27 | res = _default
28 | }
29 | return res
30 | }
31 |
32 | export function formatCurrency(price) {
33 | return new Intl.NumberFormat('zh-CN', {
34 | minimumFractionDigits: price < 100 ? 2 : 0
35 | }).format(price)
36 | }
37 |
38 | export function getTimeTextHourMin(date) {
39 | let hour = date.getHours()
40 | let min = `00${date.getMinutes()}`.slice(-2)
41 | return `${hour}:${min}`
42 | }
43 |
44 | export function getUuid4Hex() {
45 | let chars = []
46 | for (let i = 0; i < 32; i++) {
47 | let char = Math.floor(Math.random() * 16).toString(16)
48 | chars.push(char)
49 | }
50 | return chars.join('')
51 | }
52 |
--------------------------------------------------------------------------------
/frontend/src/utils/pronunciation/index.js:
--------------------------------------------------------------------------------
1 | export const DICT_PINYIN = 'pinyin'
2 | export const DICT_KANA = 'kana'
3 |
4 | export class PronunciationConverter {
5 | constructor() {
6 | this.pronunciationMap = new Map()
7 | }
8 |
9 | async loadDict(dictName) {
10 | let promise
11 | switch (dictName) {
12 | case DICT_PINYIN:
13 | promise = import('./dictPinyin')
14 | break
15 | case DICT_KANA:
16 | promise = import('./dictKana')
17 | break
18 | default:
19 | return
20 | }
21 |
22 | let dictTxt = (await promise).default
23 | let pronunciationMap = new Map()
24 | for (let item of dictTxt.split('\n')) {
25 | if (item.length === 0) {
26 | continue
27 | }
28 | pronunciationMap.set(item.substring(0, 1), item.substring(1))
29 | }
30 | this.pronunciationMap = pronunciationMap
31 | }
32 |
33 | getPronunciation(text) {
34 | let res = []
35 | let lastHasPronunciation = null
36 | for (let char of text) {
37 | let pronunciation = this.pronunciationMap.get(char)
38 | if (pronunciation === undefined) {
39 | if (lastHasPronunciation !== null && lastHasPronunciation) {
40 | res.push(' ')
41 | }
42 | lastHasPronunciation = false
43 | res.push(char)
44 | } else {
45 | if (lastHasPronunciation !== null) {
46 | res.push(' ')
47 | }
48 | lastHasPronunciation = true
49 | res.push(pronunciation)
50 | }
51 | }
52 | return res.join('')
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/frontend/src/utils/trie.js:
--------------------------------------------------------------------------------
1 | export class Trie {
2 | constructor() {
3 | this._root = this._createNode()
4 | }
5 |
6 | _createNode() {
7 | return {
8 | children: {}, // char -> node
9 | value: null
10 | }
11 | }
12 |
13 | set(key, value) {
14 | if (key === '') {
15 | throw new Error('key is empty')
16 | }
17 | let node = this._root
18 | for (let char of key) {
19 | let nextNode = node.children[char]
20 | if (nextNode === undefined) {
21 | nextNode = node.children[char] = this._createNode()
22 | }
23 | node = nextNode
24 | }
25 | node.value = value
26 | }
27 |
28 | get(key) {
29 | let node = this._root
30 | for (let char of key) {
31 | let nextNode = node.children[char]
32 | if (nextNode === undefined) {
33 | return null
34 | }
35 | node = nextNode
36 | }
37 | return node.value
38 | }
39 |
40 | has(key) {
41 | return this.get(key) !== null
42 | }
43 |
44 | lazyMatch(str) {
45 | let node = this._root
46 | for (let char of str) {
47 | let nextNode = node.children[char]
48 | if (nextNode === undefined) {
49 | return null
50 | }
51 | if (nextNode.value !== null) {
52 | return nextNode.value
53 | }
54 | node = nextNode
55 | }
56 | return null
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/frontend/src/views/Help.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{ $t('help.help') }}
4 |
{{ $t('help.p1_1') }} https://play-live.bilibili.com/ {{ $t('help.p1_2') }}
5 |
6 |
{{ $t('help.p2') }}
7 |
8 |
{{ $t('help.p3') }}
9 |
10 |
{{ $t('help.p4') }}
11 |
12 |
{{ $t('help.p5') }}
13 |
14 |
--------------------------------------------------------------------------------------------------------
15 |
使用前必看:注意事项和常见问题
16 |
喜欢的话可以推荐给别人 _(:з」∠)_
17 |
18 |
19 |
20 |
25 |
--------------------------------------------------------------------------------
/frontend/src/views/Home/TemplateSelect.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{$t('home.templateHelp')}}
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
{{ $t('home.templateDefaultTitle') }}
15 |
16 |
17 |
18 |
{{ $t('home.templateDefaultDescription') }}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
{{ $t('home.templateCustomUrlTitle') }}
28 |
29 |
30 |
31 |
{{ $t('home.templateCustomUrlDescription') }}
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
{{ template.name }}
44 | {{ template.version }}
45 | {{ $t('home.author') }}{{ template.author }}
46 |
47 |
48 |
49 |
50 |
{{ template.description }}
51 |
URL: {{ template.url }}
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
138 |
139 |
223 |
--------------------------------------------------------------------------------
/frontend/src/views/NotFound.vue:
--------------------------------------------------------------------------------
1 |
2 | 404: Not Found
3 |
4 |
5 |
10 |
--------------------------------------------------------------------------------
/frontend/src/views/Plugins.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
{{ $t('plugins.plugins') }}
5 |
{{
6 | $t('plugins.help')
7 | }}
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
{{ plugin.name }}
20 | {{ plugin.version }}
21 | {{ $t('plugins.author') }}{{ plugin.author }}
22 |
23 |
{{ plugin.description }}
24 |
25 |
26 |
27 | {{ $t('plugins.admin') }}
30 |
31 | {{ $t('plugins.connected') }}
32 | {{ $t('plugins.unconnected') }}
33 | setPluginEnabled(plugin, enabled)"
35 | >
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
128 |
129 |
184 |
--------------------------------------------------------------------------------
/frontend/src/views/StyleGenerator/FontSelect.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
14 |
15 |
16 | {{ font }}
17 | deleteRecentFont(font)"
19 | >
20 |
21 |
22 |
23 | Sample 样例 サンプル
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
120 |
121 |
126 |
127 |
162 |
--------------------------------------------------------------------------------
/frontend/src/views/StyleGenerator/common.js:
--------------------------------------------------------------------------------
1 | import * as fonts from './fonts'
2 |
3 | const FALLBACK_FONTS_CSS = '"Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", \
4 | "\\5FAE \\8F6F \\96C5 \\9ED1 ", SimHei, Arial, sans-serif'
5 |
6 | export const COMMON_STYLE = `/* Transparent background */
7 | yt-live-chat-renderer {
8 | background-color: transparent !important;
9 | }
10 |
11 | yt-live-chat-ticker-renderer {
12 | background-color: transparent !important;
13 | box-shadow: none !important;
14 | }
15 |
16 | yt-live-chat-author-chip #author-name {
17 | background-color: transparent !important;
18 | }
19 |
20 | /* Hide scrollbar */
21 | yt-live-chat-item-list-renderer #items {
22 | overflow: hidden !important;
23 | }
24 |
25 | yt-live-chat-item-list-renderer #item-scroller {
26 | overflow: hidden !important;
27 | }
28 |
29 | yt-live-chat-text-message-renderer #content,
30 | yt-live-chat-membership-item-renderer #content {
31 | overflow: visible !important;
32 | }
33 |
34 | /* Hide header and input */
35 | yt-live-chat-header-renderer,
36 | yt-live-chat-message-input-renderer {
37 | display: none !important;
38 | }
39 |
40 | /* Hide unimportant messages */
41 | yt-live-chat-text-message-renderer[is-deleted],
42 | yt-live-chat-membership-item-renderer[is-deleted] {
43 | display: none !important;
44 | }
45 |
46 | yt-live-chat-mode-change-message-renderer,
47 | yt-live-chat-viewer-engagement-message-renderer,
48 | yt-live-chat-restricted-participation-renderer {
49 | display: none !important;
50 | }
51 |
52 | yt-live-chat-text-message-renderer a,
53 | yt-live-chat-membership-item-renderer a {
54 | text-decoration: none !important;
55 | }`
56 |
57 | export function getImportStyle(allFontsStrs) {
58 | let allFonts = new Set()
59 | for (let fontsStr of allFontsStrs) {
60 | for (let font of fontsStrToArr(fontsStr)) {
61 | allFonts.add(font)
62 | }
63 | }
64 |
65 | let fontsNeedToImport = new Set()
66 | for (let font of allFonts) {
67 | if (fonts.NETWORK_FONTS.indexOf(font) !== -1) {
68 | fontsNeedToImport.add(font)
69 | }
70 | }
71 | let res = []
72 | for (let font of fontsNeedToImport) {
73 | res.push(`@import url("https://fonts.googleapis.com/css?family=${encodeURIComponent(font)}");`)
74 | }
75 | return res.join('\n')
76 | }
77 |
78 | export function getAvatarStyle(config) {
79 | return `/* Avatars */
80 | yt-live-chat-text-message-renderer #author-photo,
81 | yt-live-chat-text-message-renderer #author-photo img,
82 | yt-live-chat-paid-message-renderer #author-photo,
83 | yt-live-chat-paid-message-renderer #author-photo img,
84 | yt-live-chat-membership-item-renderer #author-photo,
85 | yt-live-chat-membership-item-renderer #author-photo img {
86 | ${config.showAvatars ? '' : 'display: none !important;'}
87 | width: ${config.avatarSize}px !important;
88 | height: ${config.avatarSize}px !important;
89 | border-radius: ${config.avatarSize}px !important;
90 | margin-right: ${config.avatarSize / 4}px !important;
91 | }`
92 | }
93 |
94 | export function getTimeStyle(config) {
95 | return `/* Timestamps */
96 | yt-live-chat-text-message-renderer #timestamp {
97 | display: ${config.showTime ? 'inline' : 'none'} !important;
98 | ${config.timeColor ? `color: ${config.timeColor} !important;` : ''}
99 | font-family: ${fontsStrToCss(config.timeFont)};
100 | font-size: ${config.timeFontSize}px !important;
101 | line-height: ${config.timeLineHeight || config.timeFontSize}px !important;
102 | }`
103 | }
104 |
105 | export function getAnimationStyle(config) {
106 | if (!config.animateIn && !config.animateOut) {
107 | return ''
108 | }
109 | let totalTime = 0
110 | if (config.animateIn) {
111 | totalTime += config.fadeInTime
112 | }
113 | if (config.animateOut) {
114 | totalTime += config.animateOutWaitTime * 1000
115 | totalTime += config.fadeOutTime
116 | }
117 | let keyframes = []
118 | let curTime = 0
119 | if (config.animateIn) {
120 | keyframes.push(` 0% { opacity: 0;${!config.slide ? ''
121 | : ` translate: ${config.reverseSlide ? 16 : -16}px;`
122 | } }`)
123 | curTime += config.fadeInTime
124 | keyframes.push(` ${curTime / totalTime * 100}% { opacity: 1; translate: none; }`)
125 | }
126 | if (config.animateOut) {
127 | curTime += config.animateOutWaitTime * 1000
128 | keyframes.push(` ${curTime / totalTime * 100}% { opacity: 1; translate: none; }`)
129 | curTime += config.fadeOutTime
130 | keyframes.push(` ${curTime / totalTime * 100}% { opacity: 0;${!config.slide ? ''
131 | : ` translate: ${config.reverseSlide ? -16 : 16}px;`
132 | } }`)
133 | }
134 | return `/* Animation */
135 | @keyframes anim {
136 | ${keyframes.join('\n')}
137 | }
138 |
139 | yt-live-chat-item-list-renderer #items > * {
140 | animation: anim ${totalTime}ms;
141 | animation-fill-mode: both;
142 | }`
143 | }
144 |
145 | export function fontsStrToArr(fontsStr) {
146 | return fontsStr ? fontsStr.split(',') : []
147 | }
148 |
149 | export function fontsArrToStr(fontsArr) {
150 | return fontsArr.join(',')
151 | }
152 |
153 | export function fontsStrToCss(fontsStr) {
154 | let fontsArr = fontsStrToArr(fontsStr)
155 | if (fontsArr.length === 0) {
156 | return FALLBACK_FONTS_CSS
157 | }
158 | fontsArr = fontsArr.map(cssEscapeStr)
159 | fontsArr.push(FALLBACK_FONTS_CSS)
160 | return fontsArr.join(', ')
161 | }
162 |
163 | function cssEscapeStr(str) {
164 | let res = []
165 | for (let char of str) {
166 | res.push(cssEscapeChar(char))
167 | }
168 | return `"${res.join('')}"`
169 | }
170 |
171 | function cssEscapeChar(char) {
172 | if (!needEscapeChar(char)) {
173 | return char
174 | }
175 | let hexCode = char.codePointAt(0).toString(16)
176 | // https://drafts.csswg.org/cssom/#escape-a-character-as-code-point
177 | return `\\${hexCode} `
178 | }
179 |
180 | function needEscapeChar(char) {
181 | let code = char.codePointAt(0)
182 | if (0x20 <= code && code <= 0x7E) {
183 | return char === '"' || char === '\\'
184 | }
185 | return true
186 | }
187 |
--------------------------------------------------------------------------------
/frontend/src/views/StyleGenerator/fonts.js:
--------------------------------------------------------------------------------
1 | export const PRESET_FONTS = [
2 | 'sans-serif', 'serif', 'Tahoma', 'Arial', 'Verdana',
3 | // Windows
4 | 'Microsoft YaHei', 'Microsoft JhengHei', 'SimHei', 'SimSun', 'FangSong',
5 | // Mac
6 | 'Helvetica', 'PingFang SC', 'Hiragino Sans GB',
7 | // Linux
8 | 'WenQuanYi Micro Hei',
9 | ]
10 |
11 | // https://fonts.google.com/
12 | export const NETWORK_FONTS = [
13 | 'Changa One',
14 | 'Imprima',
15 | 'Inter',
16 | 'Jacquard 24',
17 | 'Jacquarda Bastarda 9 Charted',
18 | 'Jersey 10 Charted',
19 | 'Lato',
20 | 'Liu Jian Mao Cao',
21 | 'Long Cang',
22 | 'Ma Shan Zheng',
23 | 'Micro 5 Charted',
24 | 'Montserrat',
25 | 'Noto Sans',
26 | 'Noto Sans HK',
27 | 'Noto Sans JP',
28 | 'Noto Sans KR',
29 | 'Noto Sans SC',
30 | 'Noto Sans TC',
31 | 'Noto Serif',
32 | 'Noto Serif HK',
33 | 'Noto Serif JP',
34 | 'Noto Serif KR',
35 | 'Noto Serif SC',
36 | 'Noto Serif TC',
37 | 'Open Sans',
38 | 'Oswald',
39 | 'Platypi',
40 | 'Poppins',
41 | 'Roboto',
42 | 'Roboto Condensed',
43 | 'Sedan',
44 | 'ZCOOL KuaiLe',
45 | 'ZCOOL QingKe HuangYou',
46 | 'ZCOOL XiaoWei',
47 | 'Zhi Mang Xing',
48 | ]
49 |
50 | let localFontsPromise = null
51 |
52 | export async function getLocalFonts() {
53 | if (!localFontsPromise) {
54 | localFontsPromise = doGetLocalFonts()
55 | }
56 | return localFontsPromise
57 | }
58 |
59 | async function doGetLocalFonts() {
60 | try {
61 | return (await window.queryLocalFonts()).map(fontData => {
62 | // 理论上应该用family,但fullName可读性更好
63 | return fontData.fullName
64 | })
65 | } catch (e) {
66 | console.error(e)
67 | return []
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/frontend/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://openapi.vercel.sh/vercel.json",
3 | "cleanUrls": true,
4 | "trailingSlash": false,
5 | "headers": [
6 | {
7 | "source": "/((?!api/)[^.]*)",
8 | "headers": [
9 | {
10 | "key": "Cache-Control",
11 | "value": "public, max-age=180"
12 | }
13 | ]
14 | },
15 | {
16 | "source": "/((?!api/).+\\.\\w+)",
17 | "headers": [
18 | {
19 | "key": "Cache-Control",
20 | "value": "public, max-age=86400"
21 | }
22 | ]
23 | }
24 | ],
25 | "rewrites": [
26 | {
27 | "source": "/((?!api/)[^.]+)",
28 | "destination": "/"
29 | }
30 | ]
31 | }
32 |
--------------------------------------------------------------------------------
/frontend/vue.config.js:
--------------------------------------------------------------------------------
1 | const { defineConfig } = require('@vue/cli-service')
2 |
3 | // 不能用localhost,https://forum.dfinity.org/t/development-workflow-quickly-test-code-modifications/1793/21
4 | const API_BASE_URL = 'http://127.0.0.1:12450'
5 |
6 | function toBool(val) {
7 | if (typeof val === 'string') {
8 | return ['false', 'no', 'off', '0', ''].indexOf(val.toLowerCase()) === -1
9 | }
10 | return Boolean(val)
11 | }
12 |
13 | module.exports = defineConfig({
14 | devServer: {
15 | proxy: {
16 | '/api': {
17 | target: API_BASE_URL,
18 | ws: true
19 | },
20 | '/emoticons': {
21 | target: API_BASE_URL
22 | },
23 | '/custom_public': {
24 | target: API_BASE_URL
25 | },
26 | }
27 | },
28 | productionSourceMap: toBool(process.env.PROD_SOURCE_MAP),
29 | chainWebpack: config => {
30 | const APP_VERSION = `v${process.env.npm_package_version}`
31 | const LIB_USE_CDN = toBool(process.env.LIB_USE_CDN)
32 |
33 | const ENV = {
34 | APP_VERSION,
35 | LIB_USE_CDN,
36 | BACKEND_DISCOVERY: toBool(process.env.BACKEND_DISCOVERY),
37 | }
38 | config.plugin('define')
39 | .tap(args => {
40 | let defineMap = args[0]
41 | let env = defineMap['process.env']
42 | for (let [name, value] of Object.entries(ENV)) {
43 | env[name] = JSON.stringify(value)
44 | }
45 | return args
46 | })
47 |
48 | if (LIB_USE_CDN) {
49 | config.externals({
50 | 'element-ui': 'ELEMENT',
51 | lodash: '_',
52 | pako: 'pako',
53 | vue: 'Vue',
54 | 'vue-router': 'VueRouter',
55 | 'vue-i18n': 'VueI18n',
56 | })
57 | }
58 | }
59 | })
60 |
--------------------------------------------------------------------------------
/log/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/log/.gitkeep
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | import argparse
4 | import asyncio
5 | import logging
6 | import logging.handlers
7 | import os
8 | import signal
9 | import sys
10 | import webbrowser
11 | from typing import *
12 |
13 | import tornado.ioloop
14 | import tornado.web
15 |
16 | import api.chat
17 | import api.main
18 | import api.open_live
19 | import api.plugin
20 | import config
21 | import models.database
22 | import services.avatar
23 | import services.chat
24 | import services.open_live
25 | import services.plugin
26 | import services.translate
27 | import update
28 | import utils.request
29 |
30 | logger = logging.getLogger(__name__)
31 |
32 | ROUTES = [
33 | *api.main.ROUTES,
34 | *api.chat.ROUTES,
35 | *api.open_live.ROUTES,
36 | *api.plugin.ROUTES,
37 | *api.main.LAST_ROUTES,
38 | ]
39 |
40 | server: Optional[tornado.httpserver.HTTPServer] = None
41 |
42 | cmd_args = None
43 | shut_down_event: Optional[asyncio.Event] = None
44 |
45 |
46 | async def main():
47 | if not init():
48 | return 1
49 | try:
50 | await run()
51 | finally:
52 | await shut_down()
53 | return 0
54 |
55 |
56 | def init():
57 | init_signal_handlers()
58 |
59 | global cmd_args
60 | cmd_args = parse_args()
61 |
62 | init_logging(cmd_args.debug)
63 | logger.info('App started, initializing')
64 | config.init(cmd_args)
65 |
66 | utils.request.init()
67 | models.database.init()
68 |
69 | services.avatar.init()
70 | services.translate.init()
71 | services.open_live.init()
72 | services.chat.init()
73 |
74 | init_server()
75 | if server is None:
76 | return False
77 |
78 | services.plugin.init()
79 |
80 | update.check_update()
81 | return True
82 |
83 |
84 | def init_signal_handlers():
85 | global shut_down_event
86 | shut_down_event = asyncio.Event()
87 |
88 | is_win = sys.platform == 'win32'
89 | loop = asyncio.get_running_loop()
90 | if not is_win:
91 | def add_signal_handler(signum, callback):
92 | loop.add_signal_handler(signum, callback)
93 | else:
94 | def add_signal_handler(signum, callback):
95 | # 不太安全,但Windows只能用这个
96 | signal.signal(signum, lambda _signum, _frame: loop.call_soon(callback))
97 |
98 | shut_down_signums = (signal.SIGINT, signal.SIGTERM)
99 | if not is_win:
100 | reload_signum = signal.SIGHUP
101 | else:
102 | reload_signum = signal.SIGBREAK
103 |
104 | for shut_down_signum in shut_down_signums:
105 | add_signal_handler(shut_down_signum, on_shut_down_signal)
106 | add_signal_handler(reload_signum, on_reload_signal)
107 |
108 |
109 | def on_shut_down_signal():
110 | shut_down_event.set()
111 |
112 |
113 | def on_reload_signal():
114 | logger.info('Received reload signal')
115 | config.reload(cmd_args)
116 |
117 |
118 | def parse_args():
119 | parser = argparse.ArgumentParser(description='用于OBS的仿YouTube风格的bilibili直播评论栏')
120 | parser.add_argument('--host', help='服务器host,默认和配置中的一样', default=None)
121 | parser.add_argument('--port', help='服务器端口,默认和配置中的一样', type=int, default=None)
122 | parser.add_argument('--debug', help='调试模式', action='store_true')
123 | return parser.parse_args()
124 |
125 |
126 | def init_logging(debug):
127 | filename = os.path.join(config.BASE_PATH, 'log', 'blivechat.log')
128 | stream_handler = logging.StreamHandler()
129 | file_handler = logging.handlers.TimedRotatingFileHandler(
130 | filename, encoding='utf-8', when='midnight', backupCount=7, delay=True
131 | )
132 | logging.basicConfig(
133 | format='{asctime} {levelname} [{name}]: {message}',
134 | style='{',
135 | level=logging.INFO if not debug else logging.DEBUG,
136 | handlers=[stream_handler, file_handler]
137 | )
138 |
139 | # 屏蔽访问日志
140 | logging.getLogger('tornado.access').setLevel(logging.WARNING)
141 |
142 |
143 | def init_server():
144 | cfg = config.get_config()
145 | app = tornado.web.Application(
146 | ROUTES,
147 | websocket_ping_interval=10,
148 | debug=cfg.debug,
149 | autoreload=False
150 | )
151 | try:
152 | global server
153 | server = app.listen(
154 | cfg.port,
155 | cfg.host,
156 | xheaders=cfg.tornado_xheaders,
157 | max_body_size=1024 * 1024,
158 | max_buffer_size=1024 * 1024
159 | )
160 | except OSError:
161 | logger.warning('Address is used %s:%d', cfg.host, cfg.port)
162 | return
163 | finally:
164 | if cfg.open_browser_at_startup:
165 | url = 'http://localhost/' if cfg.port == 80 else f'http://localhost:{cfg.port}/'
166 | webbrowser.open(url)
167 | logger.info('Server started: %s:%d', cfg.host, cfg.port)
168 |
169 |
170 | async def run():
171 | logger.info('Running event loop')
172 | await shut_down_event.wait()
173 | logger.info('Received shutdown signal')
174 |
175 |
176 | async def shut_down():
177 | services.plugin.shut_down()
178 |
179 | logger.info('Closing server')
180 | server.stop()
181 | await server.close_all_connections()
182 |
183 | logger.info('Closing websocket connections')
184 | await services.chat.shut_down()
185 |
186 | await utils.request.shut_down()
187 |
188 | logger.info('App shut down')
189 |
190 |
191 | if __name__ == '__main__':
192 | sys.exit(asyncio.run(main()))
193 |
--------------------------------------------------------------------------------
/models/bilibili.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import datetime
3 |
4 | import sqlalchemy
5 | from sqlalchemy.orm import Mapped, mapped_column
6 |
7 | import models.database
8 |
9 |
10 | class BilibiliUser(models.database.OrmBase):
11 | __tablename__ = 'bilibili_users'
12 | uid: Mapped[int] = mapped_column(sqlalchemy.BigInteger, primary_key=True) # 创建表后最好手动改成unsigned
13 | avatar_url: Mapped[str] = mapped_column(sqlalchemy.String(100))
14 | update_time: Mapped[datetime.datetime]
15 |
--------------------------------------------------------------------------------
/models/database.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from typing import *
3 |
4 | import sqlalchemy.orm
5 |
6 | import config
7 |
8 | _engine: Optional[sqlalchemy.Engine] = None
9 |
10 |
11 | class OrmBase(sqlalchemy.orm.DeclarativeBase):
12 | pass
13 |
14 |
15 | def init():
16 | cfg = config.get_config()
17 | global _engine
18 | _engine = sqlalchemy.create_engine(
19 | cfg.database_url,
20 | pool_size=5, # 保持的连接数
21 | max_overflow=5, # 临时的额外连接数
22 | pool_timeout=3, # 连接数达到最大时获取新连接的超时时间
23 | # pool_pre_ping=True, # 获取连接时先检测是否可用
24 | pool_recycle=60 * 60, # 回收超过1小时的连接,防止数据库服务器主动断开不活跃的连接
25 | # echo=cfg.debug, # 输出SQL语句
26 | )
27 |
28 | OrmBase.metadata.create_all(_engine)
29 |
30 |
31 | def get_session() -> sqlalchemy.orm.Session:
32 | return sqlalchemy.orm.Session(_engine)
33 |
--------------------------------------------------------------------------------
/plugins/msg-logging/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 xfgryujk
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/plugins/msg-logging/config.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import os
3 |
4 | BASE_PATH = os.path.dirname(os.path.realpath(__file__))
5 | LOG_PATH = os.path.join(BASE_PATH, 'log')
6 |
--------------------------------------------------------------------------------
/plugins/msg-logging/listener.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import __main__
3 | import datetime
4 | import logging
5 | import os
6 | import sys
7 | from typing import *
8 |
9 | import blcsdk
10 | import blcsdk.models as sdk_models
11 | import config
12 |
13 | logger = logging.getLogger('msg-logging.' + __name__)
14 |
15 | _msg_handler: Optional['MsgHandler'] = None
16 | _id_room_dict: Dict[int, 'Room'] = {}
17 |
18 |
19 | async def init():
20 | global _msg_handler
21 | _msg_handler = MsgHandler()
22 | blcsdk.set_msg_handler(_msg_handler)
23 |
24 | # 创建已有的房间。这一步失败了也没关系,只是有消息时才会创建文件
25 | try:
26 | blc_rooms = await blcsdk.get_rooms()
27 | for blc_room in blc_rooms:
28 | if blc_room.room_id is not None:
29 | _get_or_add_room(blc_room.room_id)
30 | except blcsdk.SdkError:
31 | pass
32 |
33 |
34 | def shut_down():
35 | blcsdk.set_msg_handler(None)
36 | while len(_id_room_dict) != 0:
37 | room_id = next(iter(_id_room_dict))
38 | _del_room(room_id)
39 |
40 |
41 | class MsgHandler(blcsdk.BaseHandler):
42 | def on_client_stopped(self, client: blcsdk.BlcPluginClient, exception: Optional[Exception]):
43 | logger.info('blivechat disconnected')
44 | __main__.start_shut_down()
45 |
46 | def _on_open_plugin_admin_ui(
47 | self, client: blcsdk.BlcPluginClient, message: sdk_models.OpenPluginAdminUiMsg, extra: sdk_models.ExtraData
48 | ):
49 | if sys.platform == 'win32':
50 | os.startfile(config.LOG_PATH)
51 | else:
52 | logger.info('Log path is "%s"', config.LOG_PATH)
53 |
54 | def _on_room_init(
55 | self, client: blcsdk.BlcPluginClient, message: sdk_models.RoomInitMsg, extra: sdk_models.ExtraData
56 | ):
57 | if extra.is_from_plugin:
58 | return
59 | if message.is_success:
60 | _get_or_add_room(extra.room_id)
61 |
62 | def _on_del_room(self, client: blcsdk.BlcPluginClient, message: sdk_models.DelRoomMsg, extra: sdk_models.ExtraData):
63 | if extra.is_from_plugin:
64 | return
65 | if extra.room_id is not None:
66 | _del_room(extra.room_id)
67 |
68 | def _on_add_text(self, client: blcsdk.BlcPluginClient, message: sdk_models.AddTextMsg, extra: sdk_models.ExtraData):
69 | if extra.is_from_plugin:
70 | return
71 | room = _get_or_add_room(extra.room_id)
72 | room.log(f'[dm] {message.author_name}:{message.content}')
73 |
74 | def _on_add_gift(self, client: blcsdk.BlcPluginClient, message: sdk_models.AddGiftMsg, extra: sdk_models.ExtraData):
75 | if extra.is_from_plugin:
76 | return
77 | room = _get_or_add_room(extra.room_id)
78 | if message.total_coin != 0:
79 | content = (
80 | f'[paid_gift] {message.author_name} 赠送了 {message.gift_name} x {message.num},'
81 | f'总价 {message.total_coin / 1000:.1f} 元'
82 | )
83 | else:
84 | content = (
85 | f'[free_gift] {message.author_name} 赠送了 {message.gift_name} x {message.num},'
86 | f'总价 {message.total_free_coin} 银瓜子'
87 | )
88 | room.log(content)
89 |
90 | def _on_add_member(
91 | self, client: blcsdk.BlcPluginClient, message: sdk_models.AddMemberMsg, extra: sdk_models.ExtraData
92 | ):
93 | if extra.is_from_plugin:
94 | return
95 | room = _get_or_add_room(extra.room_id)
96 | if message.privilege_type == sdk_models.GuardLevel.LV1:
97 | guard_name = '舰长'
98 | elif message.privilege_type == sdk_models.GuardLevel.LV2:
99 | guard_name = '提督'
100 | elif message.privilege_type == sdk_models.GuardLevel.LV3:
101 | guard_name = '总督'
102 | else:
103 | guard_name = '未知舰队等级'
104 | room.log(f'[guard] {message.author_name} 购买了 {message.num}{message.unit} {guard_name},'
105 | f'总价 {message.total_coin / 1000:.1f} 元')
106 |
107 | def _on_add_super_chat(
108 | self, client: blcsdk.BlcPluginClient, message: sdk_models.AddSuperChatMsg, extra: sdk_models.ExtraData
109 | ):
110 | if extra.is_from_plugin:
111 | return
112 | room = _get_or_add_room(extra.room_id)
113 | room.log(f'[superchat] {message.author_name} 发送了 {message.price} 元的醒目留言:{message.content}')
114 |
115 |
116 | def _get_or_add_room(room_id):
117 | room = _id_room_dict.get(room_id, None)
118 | if room is None:
119 | if room_id is None:
120 | raise TypeError('room_id is None')
121 | room = _id_room_dict[room_id] = Room(room_id)
122 | return room
123 |
124 |
125 | def _del_room(room_id):
126 | room = _id_room_dict.pop(room_id, None)
127 | if room is not None:
128 | room.close()
129 |
130 |
131 | class Room:
132 | def __init__(self, room_id):
133 | cur_time = datetime.datetime.now()
134 | time_str = cur_time.strftime('%Y%m%d_%H%M%S')
135 | filename = f'room_{room_id}-{time_str}.txt'
136 | self._file = open(os.path.join(config.LOG_PATH, filename), 'a', encoding='utf-8-sig')
137 |
138 | def close(self):
139 | self._file.close()
140 |
141 | def log(self, content):
142 | cur_time = datetime.datetime.now()
143 | time_str = cur_time.strftime('%Y-%m-%d %H:%M:%S')
144 | text = f'{time_str} {content}\n'
145 | self._file.write(text)
146 | self._file.flush()
147 |
--------------------------------------------------------------------------------
/plugins/msg-logging/log/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/plugins/msg-logging/log/.gitkeep
--------------------------------------------------------------------------------
/plugins/msg-logging/main.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | import asyncio
4 | import logging.handlers
5 | import os
6 | import signal
7 | import sys
8 | from typing import *
9 |
10 | import blcsdk
11 | import config
12 | import listener
13 |
14 | logger = logging.getLogger('msg-logging')
15 |
16 | shut_down_event: Optional[asyncio.Event] = None
17 |
18 |
19 | async def main():
20 | try:
21 | await init()
22 | await run()
23 | finally:
24 | await shut_down()
25 | return 0
26 |
27 |
28 | async def init():
29 | init_signal_handlers()
30 |
31 | init_logging()
32 |
33 | await blcsdk.init()
34 | if not blcsdk.is_sdk_version_compatible():
35 | raise RuntimeError('SDK version is not compatible')
36 |
37 | await listener.init()
38 |
39 |
40 | def init_signal_handlers():
41 | global shut_down_event
42 | shut_down_event = asyncio.Event()
43 |
44 | signums = (signal.SIGINT, signal.SIGTERM)
45 | try:
46 | loop = asyncio.get_running_loop()
47 | for signum in signums:
48 | loop.add_signal_handler(signum, start_shut_down)
49 | except NotImplementedError:
50 | # 不太安全,但Windows只能用这个
51 | for signum in signums:
52 | signal.signal(signum, start_shut_down)
53 |
54 |
55 | def start_shut_down(*_args):
56 | shut_down_event.set()
57 |
58 |
59 | def init_logging():
60 | filename = os.path.join(config.LOG_PATH, 'msg-logging.log')
61 | stream_handler = logging.StreamHandler()
62 | file_handler = logging.handlers.TimedRotatingFileHandler(
63 | filename, encoding='utf-8', when='midnight', backupCount=7, delay=True
64 | )
65 | logging.basicConfig(
66 | format='{asctime} {levelname} [{name}]: {message}',
67 | style='{',
68 | level=logging.INFO,
69 | # level=logging.DEBUG,
70 | handlers=[stream_handler, file_handler],
71 | )
72 |
73 |
74 | async def run():
75 | logger.info('Running event loop')
76 | await shut_down_event.wait()
77 | logger.info('Start to shut down')
78 |
79 |
80 | async def shut_down():
81 | listener.shut_down()
82 | await blcsdk.shut_down()
83 |
84 |
85 | if __name__ == '__main__':
86 | sys.exit(asyncio.run(main()))
87 |
--------------------------------------------------------------------------------
/plugins/msg-logging/msg-logging.spec:
--------------------------------------------------------------------------------
1 | # -*- mode: python ; coding: utf-8 -*-
2 | import typing
3 | import subprocess
4 | import sys
5 | if typing.TYPE_CHECKING:
6 | import os
7 |
8 | from PyInstaller.building.api import COLLECT, EXE, PYZ
9 | from PyInstaller.building.build_main import Analysis
10 |
11 | SPECPATH = ''
12 | DISTPATH = ''
13 |
14 |
15 | # exe文件名、打包目录名
16 | NAME = 'msg-logging'
17 | # 模块搜索路径
18 | PYTHONPATH = [
19 | os.path.join(SPECPATH, '..', '..'), # 为了找到blcsdk
20 | ]
21 | # 数据
22 | DATAS = [
23 | ('plugin.json', '.'),
24 | ('LICENSE', '.'),
25 | ('log/.gitkeep', 'log'),
26 | ]
27 |
28 | block_cipher = None
29 |
30 |
31 | a = Analysis(
32 | ['main.py'],
33 | pathex=PYTHONPATH,
34 | binaries=[],
35 | datas=DATAS,
36 | hiddenimports=[],
37 | hookspath=[],
38 | hooksconfig={},
39 | runtime_hooks=[],
40 | excludes=[],
41 | win_no_prefer_redirects=False,
42 | win_private_assemblies=False,
43 | cipher=block_cipher,
44 | noarchive=False,
45 | )
46 |
47 | pyz = PYZ(
48 | a.pure,
49 | a.zipped_data,
50 | cipher=block_cipher,
51 | )
52 |
53 | exe = EXE(
54 | pyz,
55 | a.scripts,
56 | [],
57 | exclude_binaries=True,
58 | name=NAME,
59 | debug=False,
60 | bootloader_ignore_signals=False,
61 | strip=False,
62 | upx=False,
63 | console=True,
64 | disable_windowed_traceback=False,
65 | argv_emulation=False,
66 | target_arch=None,
67 | codesign_identity=None,
68 | entitlements_file=None,
69 | )
70 |
71 | coll = COLLECT(
72 | exe,
73 | a.binaries,
74 | a.zipfiles,
75 | a.datas,
76 | strip=False,
77 | upx=False,
78 | upx_exclude=[],
79 | name=NAME,
80 | )
81 |
82 | # 打包
83 | print('Start to package')
84 | subprocess.run([sys.executable, '-m', 'zipfile', '-c', NAME + '.zip', NAME], cwd=DISTPATH)
85 |
--------------------------------------------------------------------------------
/plugins/msg-logging/plugin.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "消息日志",
3 | "version": "1.0.0",
4 | "author": "xfgryujk",
5 | "description": "把收到的消息记录到文件中。点击管理打开日志目录。日志不会自动清理,记得定期清理。发布地址:https://github.com/xfgryujk/blivechat/discussions/163",
6 | "run": "msg-logging.exe",
7 | "enabled": true
8 | }
9 |
--------------------------------------------------------------------------------
/plugins/native-ui/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 xfgryujk
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/plugins/native-ui/config.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import configparser
3 | import logging
4 | import os
5 | from typing import *
6 |
7 | import pubsub.pub as pub
8 |
9 | logger = logging.getLogger('native-ui.' + __name__)
10 |
11 | BASE_PATH = os.path.dirname(os.path.realpath(__file__))
12 | LOG_PATH = os.path.join(BASE_PATH, 'log')
13 | DATA_PATH = os.path.join(BASE_PATH, 'data')
14 |
15 | SAVE_CONFIG_PATH = os.path.join(DATA_PATH, 'config.ini')
16 | CONFIG_PATH_LIST = [
17 | SAVE_CONFIG_PATH,
18 | os.path.join(DATA_PATH, 'config.example.ini')
19 | ]
20 |
21 | BLC_ICON_PATH = os.path.join(DATA_PATH, 'blivechat.ico')
22 |
23 | _config: Optional['AppConfig'] = None
24 |
25 |
26 | def init():
27 | if reload():
28 | return
29 | logger.warning('Using default config')
30 | set_config(AppConfig())
31 |
32 |
33 | def reload():
34 | config_path = ''
35 | for path in CONFIG_PATH_LIST:
36 | if os.path.exists(path):
37 | config_path = path
38 | break
39 | if config_path == '':
40 | return False
41 |
42 | config = AppConfig()
43 | if not config.load(config_path):
44 | return False
45 |
46 | set_config(config)
47 | return True
48 |
49 |
50 | def get_config():
51 | return _config
52 |
53 |
54 | def set_config(new_config: 'AppConfig'):
55 | global _config
56 | old_config = _config
57 | _config = new_config
58 |
59 | if old_config is not None and new_config is not old_config:
60 | pub.sendMessage('config_change', new_config=new_config, old_config=old_config)
61 |
62 |
63 | class AppConfig:
64 | def __init__(self):
65 | self.room_opacity = 100
66 |
67 | self.chat_url_params = self._get_default_url_params()
68 | self.paid_url_params = self._get_default_url_params()
69 |
70 | @staticmethod
71 | def _get_default_url_params():
72 | return {
73 | 'minGiftPrice': '0',
74 | 'showGiftName': 'true',
75 | 'maxNumber': '200',
76 | }
77 |
78 | def is_url_params_changed(self, other: 'AppConfig'):
79 | return self.chat_url_params != other.chat_url_params or self.paid_url_params != other.paid_url_params
80 |
81 | def load(self, path):
82 | try:
83 | config = configparser.ConfigParser()
84 | config.read(path, 'utf-8-sig')
85 |
86 | self._load_ui_config(config)
87 | self._load_url_params(config)
88 | except Exception: # noqa
89 | logger.exception('Failed to load config:')
90 | return False
91 | return True
92 |
93 | def save(self, path):
94 | try:
95 | config = configparser.ConfigParser()
96 |
97 | self._save_ui_config(config)
98 | self._save_url_params(config)
99 |
100 | tmp_path = path + '.tmp'
101 | with open(tmp_path, 'w', encoding='utf-8-sig') as f:
102 | config.write(f)
103 | os.replace(tmp_path, path)
104 | except Exception: # noqa
105 | logger.exception('Failed to save config:')
106 | return False
107 | return True
108 |
109 | def _load_ui_config(self, config: configparser.ConfigParser):
110 | ui_section = config['ui']
111 | self.room_opacity = ui_section.getint('room_opacity', self.room_opacity)
112 |
113 | def _save_ui_config(self, config: configparser.ConfigParser):
114 | config['ui'] = {
115 | 'room_opacity': str(self.room_opacity),
116 | }
117 |
118 | def _load_url_params(self, config: configparser.ConfigParser):
119 | self.chat_url_params = self._section_to_url_params(config['chat_url_params'])
120 | self.paid_url_params = self._section_to_url_params(config['paid_url_params'])
121 |
122 | @staticmethod
123 | def _section_to_url_params(section: configparser.SectionProxy):
124 | params = {}
125 | for line in section.values():
126 | key, _, value = line.partition('=')
127 | params[key.strip()] = value.strip()
128 | return params
129 |
130 | def _save_url_params(self, config: configparser.ConfigParser):
131 | config['chat_url_params'] = self._url_params_to_section(self.chat_url_params)
132 | config['paid_url_params'] = self._url_params_to_section(self.paid_url_params)
133 |
134 | @staticmethod
135 | def _url_params_to_section(url_params: dict):
136 | return {
137 | str(index): f'{key} = {value}'
138 | for index, (key, value) in enumerate(url_params.items(), 1)
139 | }
140 |
--------------------------------------------------------------------------------
/plugins/native-ui/data/blivechat.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/plugins/native-ui/data/blivechat.ico
--------------------------------------------------------------------------------
/plugins/native-ui/data/config.example.ini:
--------------------------------------------------------------------------------
1 | # 如果要修改配置,可以复制此文件并重命名为“config.ini”再修改
2 |
3 | [ui]
4 | # 房间窗口不透明度
5 | room_opacity = 100
6 |
7 |
8 | # 评论栏浏览器URL中的参数
9 | [chat_url_params]
10 | # --- 这些参数不可改变 ---
11 | # 通过服务器转发消息
12 | # 1 = relayMessagesByServer = true
13 |
14 | # --- 这些参数可以在UI中设置 ---
15 | # 自动翻译弹幕到日语
16 | 1 = autoTranslate = false
17 | # 标注打赏用户名读音
18 | 2 = giftUsernamePronunciation =
19 | # 最低显示打赏价格(元)
20 | 3 = minGiftPrice = 0
21 | # 屏蔽礼物弹幕
22 | 4 = blockGiftDanmaku = true
23 |
24 | # --- 其他的参数自己发挥 ---
25 | # 显示礼物名
26 | 5 = showGiftName = true
27 | # 最大弹幕数
28 | 6 = maxNumber = 200
29 |
30 |
31 | # 付费消息浏览器URL中的参数
32 | [paid_url_params]
33 | # --- 这些参数不可改变 ---
34 | # 通过服务器转发消息
35 | # 1 = relayMessagesByServer = true
36 | # 显示弹幕
37 | # 2 = showDanmaku = false
38 |
39 | # --- 这些参数可以在UI中设置 ---
40 | 1 = autoTranslate = false
41 | 2 = giftUsernamePronunciation =
42 | 3 = minGiftPrice = 0
43 | 4 = blockGiftDanmaku = true
44 |
45 | # --- 其他的参数自己发挥 ---
46 | 5 = showGiftName = true
47 | 6 = maxNumber = 200
48 |
--------------------------------------------------------------------------------
/plugins/native-ui/log/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/plugins/native-ui/log/.gitkeep
--------------------------------------------------------------------------------
/plugins/native-ui/main.pyw:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | import asyncio
4 | import concurrent.futures
5 | import logging
6 | import logging.handlers
7 | import os
8 | import signal
9 | import threading
10 | from typing import *
11 |
12 | import pubsub.pub as pub
13 | import wx
14 |
15 | import blcsdk
16 | import blcsdk.models as sdk_models
17 | import config
18 | import listener
19 | import ui.room_config_dialog
20 | import ui.room_frame
21 | import ui.task_bar_icon
22 |
23 | logger = logging.getLogger('native-ui')
24 |
25 | app: Optional['App'] = None
26 |
27 |
28 | def main():
29 | init_signal_handlers()
30 |
31 | init_logging()
32 | config.init()
33 |
34 | global app
35 | app = App()
36 |
37 | logger.info('Running event loop')
38 | app.MainLoop()
39 |
40 |
41 | def init_signal_handlers():
42 | def signal_handler(*_args):
43 | wx.CallAfter(start_shut_down)
44 |
45 | for signum in (signal.SIGINT, signal.SIGTERM):
46 | signal.signal(signum, signal_handler)
47 |
48 |
49 | def start_shut_down():
50 | if app is not None and app.IsMainLoopRunning():
51 | app.ExitMainLoop()
52 | else:
53 | wx.Exit()
54 |
55 |
56 | def init_logging():
57 | filename = os.path.join(config.LOG_PATH, 'native-ui.log')
58 | stream_handler = logging.StreamHandler()
59 | file_handler = logging.handlers.TimedRotatingFileHandler(
60 | filename, encoding='utf-8', when='midnight', backupCount=7, delay=True
61 | )
62 | logging.basicConfig(
63 | format='{asctime} {levelname} [{name}]: {message}',
64 | style='{',
65 | level=logging.INFO,
66 | # level=logging.DEBUG,
67 | handlers=[stream_handler, file_handler],
68 | )
69 |
70 |
71 | class App(wx.App):
72 | def __init__(self, *args, **kwargs):
73 | self._network_worker = NetworkWorker()
74 |
75 | self._dummy_timer: Optional[wx.Timer] = None
76 | self._task_bar_icon: Optional[ui.task_bar_icon.TaskBarIcon] = None
77 |
78 | self._key_room_frame_dict: Dict[sdk_models.RoomKey, ui.room_frame.RoomFrame] = {}
79 | self._room_config_dialog: Optional[ui.room_config_dialog.RoomConfigDialog] = None
80 |
81 | super().__init__(*args, clearSigInt=False, **kwargs)
82 | self.SetExitOnFrameDelete(False)
83 |
84 | def OnInit(self):
85 | # 这个定时器只是为了及时响应信号,因为只有处理UI事件时才会唤醒主线程
86 | self._dummy_timer = wx.Timer(self)
87 | self._dummy_timer.Start(1000)
88 | self.Bind(wx.EVT_TIMER, lambda _event: None, self._dummy_timer)
89 |
90 | self._task_bar_icon = ui.task_bar_icon.TaskBarIcon()
91 |
92 | pub.subscribe(self._on_add_room, 'add_room')
93 | pub.subscribe(self._on_del_room, 'del_room')
94 | pub.subscribe(self._on_room_frame_close, 'room_frame_close')
95 | pub.subscribe(self._on_add_room, 'open_room')
96 | pub.subscribe(self._on_open_room_config_dialog, 'open_room_config_dialog')
97 |
98 | self._network_worker.init()
99 | return True
100 |
101 | def OnExit(self):
102 | logger.info('Start to shut down')
103 |
104 | self._network_worker.start_shut_down()
105 | self._network_worker.join(10)
106 |
107 | return super().OnExit()
108 |
109 | def _on_add_room(self, room_key: sdk_models.RoomKey):
110 | if room_key in self._key_room_frame_dict:
111 | return
112 |
113 | room_frame = self._key_room_frame_dict[room_key] = ui.room_frame.RoomFrame(None, room_key)
114 | room_frame.Show()
115 |
116 | def _on_del_room(self, room_key: sdk_models.RoomKey):
117 | room_frame = self._key_room_frame_dict.pop(room_key, None)
118 | if room_frame is not None:
119 | room_frame.Close(True)
120 |
121 | def _on_room_frame_close(self, room_key: sdk_models.RoomKey):
122 | self._key_room_frame_dict.pop(room_key, None)
123 |
124 | def _on_open_room_config_dialog(self):
125 | if self._room_config_dialog is None or self._room_config_dialog.IsBeingDeleted():
126 | self._room_config_dialog = ui.room_config_dialog.RoomConfigDialog(None)
127 | self._room_config_dialog.Show()
128 |
129 |
130 | class NetworkWorker:
131 | def __init__(self):
132 | self._worker_thread = threading.Thread(
133 | target=asyncio.run, args=(self._worker_thread_func(),), daemon=True
134 | )
135 | self._thread_init_future = concurrent.futures.Future()
136 |
137 | self._loop: Optional[asyncio.AbstractEventLoop] = None
138 | self._shut_down_event: Optional[asyncio.Event] = None
139 |
140 | def init(self):
141 | self._worker_thread.start()
142 | self._thread_init_future.result(10)
143 |
144 | def start_shut_down(self):
145 | if self._shut_down_event is not None:
146 | self._loop.call_soon_threadsafe(self._shut_down_event.set)
147 |
148 | def join(self, timeout=None):
149 | self._worker_thread.join(timeout)
150 | return not self._worker_thread.is_alive()
151 |
152 | async def _worker_thread_func(self):
153 | self._loop = asyncio.get_running_loop()
154 | try:
155 | try:
156 | await self._init_in_worker_thread()
157 | self._thread_init_future.set_result(None)
158 | except BaseException as e:
159 | self._thread_init_future.set_exception(e)
160 | return
161 |
162 | await self._run()
163 | finally:
164 | await self._shut_down()
165 |
166 | async def _init_in_worker_thread(self):
167 | await blcsdk.init()
168 | if not blcsdk.is_sdk_version_compatible():
169 | raise RuntimeError('SDK version is not compatible')
170 |
171 | await listener.init()
172 |
173 | self._shut_down_event = asyncio.Event()
174 |
175 | async def _run(self):
176 | logger.info('Running network thread event loop')
177 | await self._shut_down_event.wait()
178 | logger.info('Network thread start to shut down')
179 |
180 | @staticmethod
181 | async def _shut_down():
182 | listener.shut_down()
183 | await blcsdk.shut_down()
184 |
185 |
186 | if __name__ == '__main__':
187 | main()
188 |
--------------------------------------------------------------------------------
/plugins/native-ui/native-ui.spec:
--------------------------------------------------------------------------------
1 | # -*- mode: python ; coding: utf-8 -*-
2 | import typing
3 | import subprocess
4 | import sys
5 | if typing.TYPE_CHECKING:
6 | import os
7 |
8 | from PyInstaller.building.api import COLLECT, EXE, PYZ
9 | from PyInstaller.building.build_main import Analysis
10 |
11 | SPECPATH = ''
12 | DISTPATH = ''
13 |
14 |
15 | # exe文件名、打包目录名
16 | NAME = 'native-ui'
17 | # 模块搜索路径
18 | PYTHONPATH = [
19 | os.path.join(SPECPATH, '..', '..'), # 为了找到blcsdk
20 | ]
21 | # 数据
22 | DATAS = [
23 | ('plugin.json', '.'),
24 | ('LICENSE', '.'),
25 | ('data/config.example.ini', 'data'),
26 | ('data/blivechat.ico', 'data'),
27 | ('log/.gitkeep', 'log'),
28 | ]
29 | # 动态库
30 | BINARIES = []
31 | if sys.platform == 'win32':
32 | import wx
33 |
34 | # https://docs.wxpython.org/wx.html2.WebView.html#phoenix-title-webview-backend-edge-msw
35 | bin_path = os.path.join(os.path.dirname(wx.__file__), 'WebView2Loader.dll')
36 | BINARIES.append((bin_path, '.'))
37 |
38 | block_cipher = None
39 |
40 |
41 | a = Analysis(
42 | ['main.pyw'],
43 | pathex=PYTHONPATH,
44 | binaries=BINARIES,
45 | datas=DATAS,
46 | hiddenimports=[],
47 | hookspath=[],
48 | hooksconfig={},
49 | runtime_hooks=[],
50 | excludes=[],
51 | win_no_prefer_redirects=False,
52 | win_private_assemblies=False,
53 | cipher=block_cipher,
54 | noarchive=False,
55 | )
56 |
57 | pyz = PYZ(
58 | a.pure,
59 | a.zipped_data,
60 | cipher=block_cipher,
61 | )
62 |
63 | exe = EXE(
64 | pyz,
65 | a.scripts,
66 | [],
67 | exclude_binaries=True,
68 | name=NAME,
69 | debug=False,
70 | bootloader_ignore_signals=False,
71 | strip=False,
72 | upx=False,
73 | console=True,
74 | disable_windowed_traceback=False,
75 | argv_emulation=False,
76 | target_arch=None,
77 | codesign_identity=None,
78 | entitlements_file=None,
79 | icon='data/blivechat.ico',
80 | )
81 |
82 | coll = COLLECT(
83 | exe,
84 | a.binaries,
85 | a.zipfiles,
86 | a.datas,
87 | strip=False,
88 | upx=False,
89 | upx_exclude=[],
90 | name=NAME,
91 | )
92 |
93 | # 打包
94 | print('Start to package')
95 | subprocess.run([sys.executable, '-m', 'zipfile', '-c', NAME + '.zip', NAME], cwd=DISTPATH)
96 |
--------------------------------------------------------------------------------
/plugins/native-ui/plugin.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "原生UI",
3 | "version": "1.0.0",
4 | "author": "xfgryujk",
5 | "description": "提供托盘图标和用来看弹幕的窗口。支持置顶窗口和设置不透明度。支持付费消息分开显示。统计弹幕数、付费用户等信息。发布地址:https://github.com/xfgryujk/blivechat/discussions/166",
6 | "run": "native-ui.exe",
7 | "enabled": true
8 | }
9 |
--------------------------------------------------------------------------------
/plugins/native-ui/requirements.txt:
--------------------------------------------------------------------------------
1 | PyPubSub==4.0.3
2 | wxPython==4.2.1
3 | XlsxWriter==3.2.0
4 |
--------------------------------------------------------------------------------
/plugins/native-ui/ui/room_config_dialog.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import copy
3 | import logging
4 | from typing import *
5 |
6 | import pubsub.pub as pub
7 | import wx
8 |
9 | import config
10 | import designer.ui_base
11 |
12 | logger = logging.getLogger('native-ui.' + __name__)
13 |
14 |
15 | class RoomConfigDialog(designer.ui_base.RoomConfigDialogBase):
16 | _GIFT_PRON_CHOICES = ('', 'pinyin', 'kana')
17 |
18 | def __init__(self, parent):
19 | super().__init__(parent)
20 |
21 | self._new_cfg_cache: Optional[config.AppConfig] = None
22 |
23 | def TransferDataToWindow(self):
24 | cfg = config.get_config()
25 | url_params: dict = cfg.chat_url_params
26 |
27 | self.opacity_slider.SetValue(cfg.room_opacity)
28 | self.auto_translate_check.SetValue(self._to_bool(url_params.get('autoTranslate', 'false')))
29 | try:
30 | gift_pron_index = self._GIFT_PRON_CHOICES.index(url_params.get('giftUsernamePronunciation', ''))
31 | except ValueError:
32 | gift_pron_index = 0
33 | self.gift_pron_choice.SetSelection(gift_pron_index)
34 | self.min_gift_price_edit.SetValue(url_params.get('minGiftPrice', '0'))
35 | self.block_gift_danmaku_check.SetValue(self._to_bool(url_params.get('blockGiftDanmaku', 'true')))
36 |
37 | return super().TransferDataToWindow()
38 |
39 | def _on_ok(self, event):
40 | try:
41 | self._new_cfg_cache = self._create_config_from_window()
42 | except Exception as e:
43 | logger.exception('_create_config_from_window failed:')
44 | wx.MessageBox(str(e), '应用设置失败', wx.OK | wx.ICON_ERROR | wx.CENTRE, self)
45 | return
46 |
47 | if (
48 | self._new_cfg_cache.is_url_params_changed(config.get_config())
49 | and wx.MessageBox(
50 | '修改部分设置需要刷新浏览器,是否继续?', '提示', wx.YES_NO | wx.CENTRE, self
51 | ) != wx.YES
52 | ):
53 | return
54 | super()._on_ok(event)
55 |
56 | def _create_config_from_window(self):
57 | cfg = copy.deepcopy(config.get_config())
58 |
59 | cfg.room_opacity = self.opacity_slider.GetValue()
60 | url_params = {
61 | 'autoTranslate': self._bool_to_str(self.auto_translate_check.GetValue()),
62 | 'giftUsernamePronunciation': self._GIFT_PRON_CHOICES[self.gift_pron_choice.GetSelection()],
63 | 'minGiftPrice': self.min_gift_price_edit.GetValue(),
64 | 'blockGiftDanmaku': self._bool_to_str(self.block_gift_danmaku_check.GetValue()),
65 | }
66 | cfg.chat_url_params.update(url_params)
67 | cfg.paid_url_params.update(url_params)
68 |
69 | return cfg
70 |
71 | def TransferDataFromWindow(self):
72 | if self._new_cfg_cache is None:
73 | logger.warning('_new_cfg_cache is None')
74 | return False
75 |
76 | config.set_config(self._new_cfg_cache)
77 | wx.CallAfter(self._new_cfg_cache.save, config.SAVE_CONFIG_PATH)
78 |
79 | return super().TransferDataFromWindow()
80 |
81 | def _on_cancel(self, event):
82 | pub.sendMessage('room_config_dialog_cancel')
83 | super()._on_cancel(event)
84 |
85 | @staticmethod
86 | def _to_bool(value):
87 | if isinstance(value, str):
88 | return value.lower() not in ('false', 'no', 'off', '0', '')
89 | return bool(value)
90 |
91 | @staticmethod
92 | def _bool_to_str(value):
93 | return 'true' if value else 'false'
94 |
95 | def _on_opacity_slider_change(self, event):
96 | pub.sendMessage('preview_room_opacity', room_opacity=self.opacity_slider.GetValue())
97 |
--------------------------------------------------------------------------------
/plugins/native-ui/ui/task_bar_icon.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import logging
3 | import sys
4 | import webbrowser
5 |
6 | import pubsub.pub as pub
7 | import wx.adv
8 |
9 | import blcsdk
10 | import config
11 | import listener
12 |
13 | if sys.platform == 'win32':
14 | IS_WIN = True
15 | # 懒得引入pywin32了
16 | import ctypes
17 | kernel32 = ctypes.windll.kernel32
18 | user32 = ctypes.windll.user32
19 | else:
20 | IS_WIN = False
21 |
22 |
23 | logger = logging.getLogger('native-ui.' + __name__)
24 |
25 |
26 | class TaskBarIcon(wx.adv.TaskBarIcon):
27 | def __init__(self):
28 | super().__init__()
29 |
30 | self.SetIcon(wx.Icon(config.BLC_ICON_PATH, wx.BITMAP_TYPE_ICO), 'blivechat')
31 |
32 | self._menu = wx.Menu()
33 | self._menu.Append(1, '打开所有房间窗口')
34 | self._menu.Append(2, '打开主页')
35 | if IS_WIN:
36 | self._menu.Append(3, '隐藏/显示控制台')
37 | self._menu.Append(wx.ID_SEPARATOR)
38 | self._menu.Append(wx.ID_EXIT, '退出')
39 |
40 | self.Bind(wx.adv.EVT_TASKBAR_LEFT_UP, self._on_open_all_rooms_click)
41 | self.Bind(wx.EVT_MENU, self._on_open_all_rooms_click, id=1)
42 | self.Bind(wx.EVT_MENU, self._on_open_browser_click, id=2)
43 | self.Bind(wx.EVT_MENU, self._on_hide_console_click, id=3)
44 | self.Bind(wx.EVT_MENU, self._on_exit_click, id=wx.ID_EXIT)
45 |
46 | pub.subscribe(self._on_open_admin_ui, 'open_admin_ui')
47 |
48 | def _on_open_admin_ui(self):
49 | self.PopupMenu(self.GetPopupMenu())
50 |
51 | def GetPopupMenu(self):
52 | return self._menu
53 |
54 | @staticmethod
55 | def _on_open_browser_click(_event):
56 | blc_port = blcsdk.get_blc_port()
57 | url = 'http://localhost/' if blc_port == 80 else f'http://localhost:{blc_port}/'
58 | webbrowser.open(url)
59 |
60 | @staticmethod
61 | def _on_open_all_rooms_click(_event):
62 | room_keys = [room.room_key for room in listener.iter_rooms()]
63 | if not room_keys:
64 | wx.MessageBox('没有任何已连接的房间', '提示')
65 | return
66 |
67 | for room_key in room_keys:
68 | pub.sendMessage('open_room', room_key=room_key)
69 |
70 | def _on_hide_console_click(self, _event):
71 | assert IS_WIN
72 | console_window_handle = self._find_console_window()
73 | if console_window_handle == 0:
74 | logger.warning('Console window not found')
75 | wx.MessageBox('找不到控制台窗口', '提示')
76 | return
77 |
78 | is_visible = user32.IsWindowVisible(console_window_handle)
79 | show_param = 0 if is_visible else 5 # SW_HIDE SW_SHOW
80 | user32.ShowWindowAsync(console_window_handle, show_param)
81 |
82 | @staticmethod
83 | def _find_console_window():
84 | assert IS_WIN
85 | console_window_handle: int = kernel32.GetConsoleWindow()
86 | if console_window_handle == 0:
87 | return 0
88 | # 兼容Windows Terminal,https://github.com/microsoft/terminal/issues/12464
89 | while True:
90 | parent_window_handle: int = user32.GetParent(console_window_handle)
91 | if parent_window_handle == 0:
92 | break
93 | console_window_handle = parent_window_handle
94 | return console_window_handle
95 |
96 | def _on_exit_click(self, _event):
97 | assert IS_WIN
98 | # 先恢复控制台显示,防止退出后无法恢复
99 | console_window_handle = self._find_console_window()
100 | if console_window_handle != 0 and not user32.IsWindowVisible(console_window_handle):
101 | user32.ShowWindowAsync(console_window_handle, 5) # SW_SHOW
102 |
103 | kernel32.GenerateConsoleCtrlEvent(0, 0) # CTRL_C_EVENT
104 |
--------------------------------------------------------------------------------
/plugins/text-to-speech/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 xfgryujk
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/plugins/text-to-speech/config.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import configparser
3 | import logging
4 | import os
5 | from typing import *
6 |
7 | logger = logging.getLogger('text-to-speech.' + __name__)
8 |
9 | BASE_PATH = os.path.dirname(os.path.realpath(__file__))
10 | LOG_PATH = os.path.join(BASE_PATH, 'log')
11 | DATA_PATH = os.path.join(BASE_PATH, 'data')
12 |
13 | CONFIG_PATH_LIST = [
14 | os.path.join(DATA_PATH, 'config.ini'),
15 | os.path.join(DATA_PATH, 'config.example.ini')
16 | ]
17 |
18 | _config: Optional['AppConfig'] = None
19 |
20 |
21 | def init():
22 | if reload():
23 | return
24 | logger.warning('Using default config')
25 | global _config
26 | _config = AppConfig()
27 |
28 |
29 | def reload():
30 | config_path = ''
31 | for path in CONFIG_PATH_LIST:
32 | if os.path.exists(path):
33 | config_path = path
34 | break
35 | if config_path == '':
36 | return False
37 |
38 | config = AppConfig()
39 | if not config.load(config_path):
40 | return False
41 |
42 | global _config
43 | _config = config
44 | return True
45 |
46 |
47 | def get_config():
48 | return _config
49 |
50 |
51 | class AppConfig:
52 | def __init__(self):
53 | self.tts_voice_id = ''
54 | self.tts_rate = 250
55 | self.tts_volume = 1.0
56 |
57 | self.max_tts_queue_size = 5
58 |
59 | self.template_text = '{author_name} 说,{content}'
60 | # self.template_free_gift = '{author_name} 赠送了{num}个{gift_name},总价{total_coin}银瓜子'
61 | self.template_free_gift = '{author_name} 赠送了{num}个{gift_name}'
62 | self.template_paid_gift = '{author_name} 赠送了{num}个{gift_name},总价{price:.1f}元'
63 | self.template_member = '{author_name} 购买了{num}{unit} {guard_name},总价{price:.1f}元'
64 | self.template_super_chat = '{author_name} 发送了{price}元的醒目留言,{content}'
65 |
66 | def load(self, path):
67 | try:
68 | config = configparser.ConfigParser()
69 | config.read(path, 'utf-8-sig')
70 |
71 | self._load_app_config(config)
72 | except Exception: # noqa
73 | logger.exception('Failed to load config:')
74 | return False
75 | return True
76 |
77 | def _load_app_config(self, config: configparser.ConfigParser):
78 | app_section = config['app']
79 | self.tts_voice_id = app_section.get('tts_voice_id', self.tts_voice_id)
80 | self.tts_rate = app_section.getint('tts_rate', self.tts_rate)
81 | self.tts_volume = app_section.getfloat('tts_volume', self.tts_volume)
82 |
83 | self.max_tts_queue_size = app_section.getint('max_tts_queue_size', self.max_tts_queue_size)
84 |
85 | self.template_text = app_section.get('template_text', self.template_text)
86 | self.template_free_gift = app_section.get('template_free_gift', self.template_free_gift)
87 | self.template_paid_gift = app_section.get('template_paid_gift', self.template_paid_gift)
88 | self.template_member = app_section.get('template_member', self.template_member)
89 | self.template_super_chat = app_section.get('template_super_chat', self.template_super_chat)
90 |
--------------------------------------------------------------------------------
/plugins/text-to-speech/data/config.example.ini:
--------------------------------------------------------------------------------
1 | # 如果要修改配置,可以复制此文件并重命名为“config.ini”再修改
2 |
3 | [app]
4 | # 语音ID,如果为空则使用默认的。取值看启动时“Available voices:”这条日志里的ID
5 | tts_voice_id =
6 | # 语速
7 | tts_rate = 250
8 | # 音量
9 | tts_volume = 1.0
10 |
11 | # 最大队列长度,未读的消息数超过这个长度则不会读新的消息
12 | max_tts_queue_size = 5
13 |
14 | # 消息模板,如果为空则不读
15 | # 弹幕
16 | template_text = {author_name} 说,{content}
17 | # 免费礼物
18 | # template_free_gift = {author_name} 赠送了{num}个{gift_name},总价{total_coin}银瓜子
19 | template_free_gift = {author_name} 赠送了{num}个{gift_name}
20 | # 付费礼物
21 | template_paid_gift = {author_name} 赠送了{num}个{gift_name},总价{price:.1f}元
22 | # 上舰
23 | template_member = {author_name} 购买了{num}{unit} {guard_name},总价{price:.1f}元
24 | # 醒目留言
25 | template_super_chat = {author_name} 发送了{price}元的醒目留言,{content}
26 |
--------------------------------------------------------------------------------
/plugins/text-to-speech/listener.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import __main__
3 | import logging
4 | import os
5 | import subprocess
6 | import sys
7 | from typing import *
8 |
9 | import blcsdk
10 | import blcsdk.models as sdk_models
11 | import config
12 | import tts
13 |
14 | logger = logging.getLogger('text-to-speech.' + __name__)
15 |
16 | _msg_handler: Optional['MsgHandler'] = None
17 |
18 |
19 | def init():
20 | global _msg_handler
21 | _msg_handler = MsgHandler()
22 | blcsdk.set_msg_handler(_msg_handler)
23 |
24 |
25 | def shut_down():
26 | blcsdk.set_msg_handler(None)
27 |
28 |
29 | class MsgHandler(blcsdk.BaseHandler):
30 | def on_client_stopped(self, client: blcsdk.BlcPluginClient, exception: Optional[Exception]):
31 | logger.info('blivechat disconnected')
32 | __main__.start_shut_down()
33 |
34 | def _on_open_plugin_admin_ui(
35 | self, client: blcsdk.BlcPluginClient, message: sdk_models.OpenPluginAdminUiMsg, extra: sdk_models.ExtraData
36 | ):
37 | config_path = ''
38 | for path in config.CONFIG_PATH_LIST:
39 | if os.path.exists(path):
40 | config_path = path
41 | break
42 | if config_path == '':
43 | logger.warning('Config file not found, candidates: %s', config.CONFIG_PATH_LIST)
44 | return
45 |
46 | if sys.platform == 'win32':
47 | subprocess.run(['explorer', '/select,' + config_path])
48 | else:
49 | logger.info('Config path is "%s"', config_path)
50 |
51 | def _on_add_text(self, client: blcsdk.BlcPluginClient, message: sdk_models.AddTextMsg, extra: sdk_models.ExtraData):
52 | if extra.is_from_plugin:
53 | return
54 | cfg = config.get_config()
55 | if cfg.template_text == '':
56 | return
57 |
58 | text = cfg.template_text.format(
59 | author_name=message.author_name,
60 | content=message.content,
61 | )
62 | tts.say_text(text)
63 |
64 | def _on_add_gift(self, client: blcsdk.BlcPluginClient, message: sdk_models.AddGiftMsg, extra: sdk_models.ExtraData):
65 | if extra.is_from_plugin:
66 | return
67 | cfg = config.get_config()
68 | is_paid_gift = message.total_coin != 0
69 | template = cfg.template_paid_gift if is_paid_gift else cfg.template_free_gift
70 | if template == '':
71 | return
72 |
73 | task = tts.GiftTtsTask(
74 | priority=tts.Priority.HIGH if is_paid_gift else tts.Priority.NORMAL,
75 | author_name=message.author_name,
76 | num=message.num,
77 | gift_name=message.gift_name,
78 | price=message.total_coin / 1000,
79 | total_coin=message.total_coin if is_paid_gift else message.total_free_coin,
80 | )
81 | tts.say(task)
82 |
83 | def _on_add_member(
84 | self, client: blcsdk.BlcPluginClient, message: sdk_models.AddMemberMsg, extra: sdk_models.ExtraData
85 | ):
86 | if extra.is_from_plugin:
87 | return
88 | cfg = config.get_config()
89 | if cfg.template_member == '':
90 | return
91 |
92 | if message.privilege_type == sdk_models.GuardLevel.LV1:
93 | guard_name = '舰长'
94 | elif message.privilege_type == sdk_models.GuardLevel.LV2:
95 | guard_name = '提督'
96 | elif message.privilege_type == sdk_models.GuardLevel.LV3:
97 | guard_name = '总督'
98 | else:
99 | guard_name = '未知舰队等级'
100 |
101 | text = cfg.template_member.format(
102 | author_name=message.author_name,
103 | num=message.num,
104 | unit=message.unit,
105 | guard_name=guard_name,
106 | price=message.total_coin / 1000,
107 | total_coin=message.total_coin,
108 | )
109 | tts.say_text(text, tts.Priority.HIGH)
110 |
111 | def _on_add_super_chat(
112 | self, client: blcsdk.BlcPluginClient, message: sdk_models.AddSuperChatMsg, extra: sdk_models.ExtraData
113 | ):
114 | if extra.is_from_plugin:
115 | return
116 | cfg = config.get_config()
117 | if cfg.template_super_chat == '':
118 | return
119 |
120 | text = cfg.template_super_chat.format(
121 | author_name=message.author_name,
122 | price=message.price,
123 | content=message.content,
124 | )
125 | tts.say_text(text, tts.Priority.HIGH)
126 |
--------------------------------------------------------------------------------
/plugins/text-to-speech/log/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/plugins/text-to-speech/log/.gitkeep
--------------------------------------------------------------------------------
/plugins/text-to-speech/main.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | import asyncio
4 | import logging.handlers
5 | import os
6 | import signal
7 | import sys
8 | from typing import *
9 |
10 | import blcsdk
11 | import config
12 | import listener
13 | import tts
14 |
15 | logger = logging.getLogger('text-to-speech')
16 |
17 | shut_down_event: Optional[asyncio.Event] = None
18 |
19 |
20 | async def main():
21 | try:
22 | if not await init():
23 | return 1
24 | await run()
25 | finally:
26 | await shut_down()
27 | return 0
28 |
29 |
30 | async def init():
31 | init_signal_handlers()
32 |
33 | init_logging()
34 | config.init()
35 |
36 | await blcsdk.init()
37 | if not blcsdk.is_sdk_version_compatible():
38 | raise RuntimeError('SDK version is not compatible')
39 |
40 | if not tts.init():
41 | return False
42 | listener.init()
43 | return True
44 |
45 |
46 | def init_signal_handlers():
47 | global shut_down_event
48 | shut_down_event = asyncio.Event()
49 |
50 | signums = (signal.SIGINT, signal.SIGTERM)
51 | try:
52 | loop = asyncio.get_running_loop()
53 | for signum in signums:
54 | loop.add_signal_handler(signum, start_shut_down)
55 | except NotImplementedError:
56 | # 不太安全,但Windows只能用这个
57 | for signum in signums:
58 | signal.signal(signum, start_shut_down)
59 |
60 |
61 | def start_shut_down(*_args):
62 | shut_down_event.set()
63 |
64 |
65 | def init_logging():
66 | filename = os.path.join(config.LOG_PATH, 'text-to-speech.log')
67 | stream_handler = logging.StreamHandler()
68 | file_handler = logging.handlers.TimedRotatingFileHandler(
69 | filename, encoding='utf-8', when='midnight', backupCount=7, delay=True
70 | )
71 | logging.basicConfig(
72 | format='{asctime} {levelname} [{name}]: {message}',
73 | style='{',
74 | level=logging.INFO,
75 | # level=logging.DEBUG,
76 | handlers=[stream_handler, file_handler],
77 | )
78 |
79 |
80 | async def run():
81 | logger.info('Running event loop')
82 | await shut_down_event.wait()
83 | logger.info('Start to shut down')
84 |
85 |
86 | async def shut_down():
87 | listener.shut_down()
88 | await blcsdk.shut_down()
89 |
90 |
91 | if __name__ == '__main__':
92 | sys.exit(asyncio.run(main()))
93 |
--------------------------------------------------------------------------------
/plugins/text-to-speech/plugin.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "语音播报",
3 | "version": "1.0.0",
4 | "author": "xfgryujk",
5 | "description": "用语音读出收到的消息。点击管理打开配置文件,编辑保存后重启插件生效。发布地址:https://github.com/xfgryujk/blivechat/discussions/164",
6 | "run": "text-to-speech.exe",
7 | "enabled": true
8 | }
9 |
--------------------------------------------------------------------------------
/plugins/text-to-speech/requirements.txt:
--------------------------------------------------------------------------------
1 | pyttsx3==2.90
2 |
--------------------------------------------------------------------------------
/plugins/text-to-speech/text-to-speech.spec:
--------------------------------------------------------------------------------
1 | # -*- mode: python ; coding: utf-8 -*-
2 | import typing
3 | import subprocess
4 | import sys
5 | if typing.TYPE_CHECKING:
6 | import os
7 |
8 | from PyInstaller.building.api import COLLECT, EXE, PYZ
9 | from PyInstaller.building.build_main import Analysis
10 |
11 | SPECPATH = ''
12 | DISTPATH = ''
13 |
14 |
15 | # exe文件名、打包目录名
16 | NAME = 'text-to-speech'
17 | # 模块搜索路径
18 | PYTHONPATH = [
19 | os.path.join(SPECPATH, '..', '..'), # 为了找到blcsdk
20 | ]
21 | # 数据
22 | DATAS = [
23 | ('plugin.json', '.'),
24 | ('LICENSE', '.'),
25 | ('data/config.example.ini', 'data'),
26 | ('log/.gitkeep', 'log'),
27 | ]
28 |
29 | block_cipher = None
30 |
31 |
32 | a = Analysis(
33 | ['main.py'],
34 | pathex=PYTHONPATH,
35 | binaries=[],
36 | datas=DATAS,
37 | hiddenimports=[],
38 | hookspath=[],
39 | hooksconfig={},
40 | runtime_hooks=[],
41 | excludes=[],
42 | win_no_prefer_redirects=False,
43 | win_private_assemblies=False,
44 | cipher=block_cipher,
45 | noarchive=False,
46 | )
47 |
48 | pyz = PYZ(
49 | a.pure,
50 | a.zipped_data,
51 | cipher=block_cipher,
52 | )
53 |
54 | exe = EXE(
55 | pyz,
56 | a.scripts,
57 | [],
58 | exclude_binaries=True,
59 | name=NAME,
60 | debug=False,
61 | bootloader_ignore_signals=False,
62 | strip=False,
63 | upx=False,
64 | console=True,
65 | disable_windowed_traceback=False,
66 | argv_emulation=False,
67 | target_arch=None,
68 | codesign_identity=None,
69 | entitlements_file=None,
70 | )
71 |
72 | coll = COLLECT(
73 | exe,
74 | a.binaries,
75 | a.zipfiles,
76 | a.datas,
77 | strip=False,
78 | upx=False,
79 | upx_exclude=[],
80 | name=NAME,
81 | )
82 |
83 | # 打包
84 | print('Start to package')
85 | subprocess.run([sys.executable, '-m', 'zipfile', '-c', NAME + '.zip', NAME], cwd=DISTPATH)
86 |
--------------------------------------------------------------------------------
/plugins/text-to-speech/tts.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import collections
3 | import dataclasses
4 | import enum
5 | import logging
6 | import threading
7 | from typing import *
8 |
9 | import pyttsx3.voice
10 |
11 | import config
12 |
13 | logger = logging.getLogger('text-to-speech.' + __name__)
14 |
15 | _tts: Optional['Tts'] = None
16 |
17 |
18 | class Priority(enum.IntEnum):
19 | HIGH = 0
20 | NORMAL = 1
21 |
22 |
23 | @dataclasses.dataclass
24 | class TtsTask:
25 | priority: Priority
26 |
27 | @property
28 | def tts_text(self):
29 | raise NotImplementedError
30 |
31 | def merge(self, task: 'TtsTask'):
32 | return False
33 |
34 |
35 | @dataclasses.dataclass
36 | class TextTtsTask(TtsTask):
37 | text: str
38 |
39 | @property
40 | def tts_text(self):
41 | return self.text
42 |
43 |
44 | @dataclasses.dataclass
45 | class GiftTtsTask(TtsTask):
46 | author_name: str
47 | num: int
48 | gift_name: str
49 | price: float
50 | total_coin: int
51 |
52 | @property
53 | def tts_text(self):
54 | cfg = config.get_config()
55 | is_paid_gift = self.price > 0.
56 | template = cfg.template_paid_gift if is_paid_gift else cfg.template_free_gift
57 | text = template.format(
58 | author_name=self.author_name,
59 | num=self.num,
60 | gift_name=self.gift_name,
61 | price=self.price,
62 | total_coin=self.total_coin,
63 | )
64 | return text
65 |
66 | def merge(self, task: 'TtsTask'):
67 | if not isinstance(task, GiftTtsTask):
68 | return False
69 | if task.author_name != self.author_name or task.gift_name != self.gift_name:
70 | return False
71 | self.num += task.num
72 | self.price += task.price
73 | self.total_coin += task.total_coin
74 | return True
75 |
76 |
77 | def init():
78 | global _tts
79 | _tts = Tts()
80 | return _tts.init()
81 |
82 |
83 | def say_text(text, priority: Priority = Priority.NORMAL):
84 | task = TextTtsTask(priority=priority, text=text)
85 | return say(task)
86 |
87 |
88 | def say(task: TtsTask):
89 | logger.debug('%s', task.tts_text)
90 | res = _tts.push_task(task)
91 | if not res:
92 | if task.priority == Priority.HIGH:
93 | logger.info('Dropped high priority task: %s', task.tts_text)
94 | else:
95 | logger.debug('Dropped task: %s', task.tts_text)
96 | return res
97 |
98 |
99 | class Tts:
100 | def __init__(self):
101 | self._worker_thread = threading.Thread(target=self._worker_thread_func, daemon=True)
102 | # COM组件必须在使用它的线程里初始化,否则使用时会有问题
103 | self._engine: Optional[pyttsx3.Engine] = None
104 | self._thread_init_event = threading.Event()
105 |
106 | cfg = config.get_config()
107 | self._task_queues = TaskQueue(cfg.max_tts_queue_size)
108 |
109 | def init(self):
110 | self._worker_thread.start()
111 | res = self._thread_init_event.wait(10)
112 | if not res:
113 | logger.error('Initializing TTS engine timed out')
114 | return res
115 |
116 | def _init_in_worker_thread(self):
117 | logger.info('Initializing TTS engine')
118 | self._engine = pyttsx3.init()
119 |
120 | voices = cast(List[pyttsx3.voice.Voice], self._engine.getProperty('voices'))
121 | logger.info('Available voices:\n%s', '\n'.join(map(str, voices)))
122 |
123 | cfg = config.get_config()
124 | if cfg.tts_voice_id != '':
125 | self._engine.setProperty('voice', cfg.tts_voice_id)
126 | self._engine.setProperty('rate', cfg.tts_rate)
127 | self._engine.setProperty('volume', cfg.tts_volume)
128 |
129 | self._thread_init_event.set()
130 |
131 | def push_task(self, task: TtsTask):
132 | return self._task_queues.push(task)
133 |
134 | def _worker_thread_func(self):
135 | self._init_in_worker_thread()
136 |
137 | logger.info('Running TTS worker')
138 | while True:
139 | task = self._task_queues.pop()
140 | self._engine.say(task.tts_text)
141 | self._engine.runAndWait()
142 |
143 |
144 | class TaskQueue:
145 | def __init__(self, max_size=None):
146 | self._max_size: Optional[int] = max_size
147 | self._queues: List[collections.deque[TtsTask]] = [
148 | collections.deque(maxlen=self._max_size) for _ in Priority
149 | ]
150 | """任务队列,索引是优先级"""
151 | self._lock = threading.Lock()
152 | self._not_empty_condition = threading.Condition(self._lock)
153 |
154 | def push(self, task: TtsTask):
155 | with self._lock:
156 | q = self._queues[task.priority]
157 |
158 | # 尝试合并
159 | try_merge_count = 0
160 | for old_task in reversed(q):
161 | if old_task.merge(task):
162 | return True
163 |
164 | try_merge_count += 1
165 | if try_merge_count >= 5:
166 | break
167 |
168 | # 没满直接push
169 | if (
170 | self._max_size is None
171 | or sum(len(q_) for q_ in self._queues) < self._max_size
172 | ):
173 | q.append(task)
174 | self._not_empty_condition.notify()
175 | return True
176 |
177 | if task.priority != Priority.HIGH:
178 | return False
179 |
180 | # 高优先级的尝试挤掉低优先级的任务
181 | lower_q = self._queues[Priority.NORMAL]
182 | try:
183 | old_task = lower_q.popleft()
184 | except IndexError:
185 | return False
186 | logger.debug('Dropped task: %s', old_task.tts_text)
187 |
188 | q.append(task)
189 | self._not_empty_condition.notify()
190 | return True
191 |
192 | def pop(self) -> TtsTask:
193 | with self._lock:
194 | while True:
195 | # 按优先级遍历查找任务
196 | for q in self._queues:
197 | try:
198 | return q.popleft()
199 | except IndexError:
200 | pass
201 |
202 | self._not_empty_condition.wait()
203 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | -r blivedm/requirements.txt
2 | cachetools==5.3.1
3 | circuitbreaker==2.0.0
4 | pycryptodome==3.19.1
5 | sqlalchemy==2.0.37
6 | tornado==6.5.1
7 |
--------------------------------------------------------------------------------
/screenshots/chrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/screenshots/chrome.png
--------------------------------------------------------------------------------
/screenshots/obs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/screenshots/obs.png
--------------------------------------------------------------------------------
/screenshots/stylegen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xfgryujk/blivechat/cb60656c78dc69730e989d9d7dd11d09d36ccf78/screenshots/stylegen.png
--------------------------------------------------------------------------------
/services/open_live.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import asyncio
3 | import dataclasses
4 | import datetime
5 | import logging
6 | from typing import *
7 |
8 | import api.open_live
9 | import config
10 | import utils.async_io
11 |
12 | logger = logging.getLogger(__name__)
13 |
14 | # 正在等待发送的心跳任务,game_id -> HeartbeatTask
15 | _game_id_heart_task_map: Dict[str, 'HeartbeatTask'] = {}
16 |
17 |
18 | @dataclasses.dataclass
19 | class HeartbeatTask:
20 | game_id: str
21 | future: 'asyncio.Future[dict]'
22 |
23 |
24 | def init():
25 | cfg = config.get_config()
26 | # 批量心跳只支持配置了开放平台的公共服务器,私有服务器用的人少,意义不大
27 | if cfg.is_open_live_configured:
28 | utils.async_io.create_task_with_ref(_game_heartbeat_consumer())
29 |
30 |
31 | async def send_game_heartbeat(game_id) -> dict:
32 | """发送项目心跳。成功则返回符合开放平台格式的结果,失败则抛出异常"""
33 | assert config.get_config().is_open_live_configured
34 | if game_id in (None, ''):
35 | raise api.open_live.BusinessError({'code': 4000, 'message': '参数错误', 'request_id': '0', 'data': None})
36 |
37 | task = _game_id_heart_task_map.get(game_id, None)
38 | if task is None:
39 | task = HeartbeatTask(
40 | game_id=game_id,
41 | future=asyncio.get_running_loop().create_future(),
42 | )
43 |
44 | _game_id_heart_task_map[game_id] = task
45 | # 限制一次发送的数量,数量太多了就立即发送
46 | if len(_game_id_heart_task_map) >= 200:
47 | await _flush_game_heartbeat_tasks()
48 |
49 | return await task.future
50 |
51 |
52 | async def _game_heartbeat_consumer():
53 | while True:
54 | try:
55 | start_time = datetime.datetime.now()
56 | await _flush_game_heartbeat_tasks()
57 | cost_time = (datetime.datetime.now() - start_time).total_seconds()
58 |
59 | # 如果等待时间太短,请求频率会太高;如果等待时间太长,前端请求、项目心跳会超时
60 | await asyncio.sleep(4 - cost_time)
61 | except Exception: # noqa
62 | logger.exception('_heartbeat_consumer error:')
63 |
64 |
65 | async def _flush_game_heartbeat_tasks():
66 | global _game_id_heart_task_map
67 | if not _game_id_heart_task_map:
68 | return
69 | game_id_task_map = _game_id_heart_task_map
70 | _game_id_heart_task_map = {}
71 |
72 | game_ids = list(game_id_task_map.keys())
73 | logger.info('Sending game batch heartbeat for %d games', len(game_ids))
74 | try:
75 | res = await api.open_live.request_open_live(
76 | api.open_live.GAME_BATCH_HEARTBEAT_OPEN_LIVE_URL,
77 | {'game_ids': game_ids},
78 | ignore_rate_limit=True
79 | )
80 | failed_game_ids = res['data']['failed_game_ids']
81 | if failed_game_ids is None: # 哪个SB后端给数组传null的
82 | failed_game_ids = set()
83 | else:
84 | failed_game_ids = set(failed_game_ids)
85 | request_id = res['request_id']
86 | except Exception as e:
87 | for task in game_id_task_map.values():
88 | task.future.set_exception(e)
89 | return
90 | if failed_game_ids:
91 | logger.info(
92 | 'Game batch heartbeat res: %d succeeded, %d failed, request_id=%s',
93 | len(game_ids) - len(failed_game_ids), len(failed_game_ids), request_id
94 | )
95 |
96 | for task in game_id_task_map.values():
97 | if task.game_id in failed_game_ids:
98 | task.future.set_exception(api.open_live.BusinessError(
99 | {'code': 7003, 'message': '心跳过期或GameId错误', 'request_id': request_id, 'data': None}
100 | ))
101 | else:
102 | task.future.set_result({'code': 0, 'message': '0', 'request_id': request_id, 'data': None})
103 |
--------------------------------------------------------------------------------
/update.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import asyncio
3 |
4 | import aiohttp
5 |
6 | import utils.async_io
7 | import utils.request
8 |
9 | VERSION = 'v1.10.0'
10 |
11 |
12 | def check_update():
13 | utils.async_io.create_task_with_ref(_do_check_update())
14 |
15 |
16 | async def _do_check_update():
17 | try:
18 | async with utils.request.http_session.get(
19 | 'https://api.github.com/repos/xfgryujk/blivechat/releases/latest'
20 | ) as r:
21 | data = await r.json()
22 | if data['name'] != VERSION:
23 | print('---------------------------------------------')
24 | print('New version available:', data['name'])
25 | print(data['body'])
26 | print('Download:', data['html_url'])
27 | print('---------------------------------------------')
28 | except aiohttp.ClientConnectionError:
29 | print('Failed to check update: connection failed')
30 | except asyncio.TimeoutError:
31 | print('Failed to check update: timeout')
32 |
--------------------------------------------------------------------------------
/utils/async_io.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import asyncio
3 |
4 | # 只用于持有Task的引用
5 | _task_refs = set()
6 |
7 |
8 | def create_task_with_ref(*args, **kwargs):
9 | """创建Task并保持引用,防止协程执行完之前就被GC"""
10 | task = asyncio.create_task(*args, **kwargs)
11 | _task_refs.add(task)
12 | task.add_done_callback(_task_refs.discard)
13 | return task
14 |
--------------------------------------------------------------------------------
/utils/rate_limit.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import datetime
3 | import logging
4 |
5 | logger = logging.getLogger(__name__)
6 |
7 |
8 | class TokenBucket:
9 | def __init__(self, tokens_per_sec, max_token_num):
10 | self._tokens_per_sec = float(tokens_per_sec)
11 | self._max_token_num = float(max_token_num)
12 | self._stored_token_num = self._max_token_num
13 | self._last_update_time = datetime.datetime.now()
14 |
15 | if self._tokens_per_sec <= 0.0 and self._max_token_num >= 1.0:
16 | logger.warning('TokenBucket token_per_sec=%f <= 0, rate has no limit', tokens_per_sec)
17 |
18 | def try_decrease_token(self):
19 | if self._tokens_per_sec <= 0.0:
20 | # self._max_token_num < 1.0 时完全禁止
21 | return self._max_token_num >= 1.0
22 |
23 | cur_time = datetime.datetime.now()
24 | last_update_time = min(self._last_update_time, cur_time) # 防止时钟回拨
25 | add_token_num = (cur_time - last_update_time).total_seconds() * self._tokens_per_sec
26 | self._stored_token_num = min(self._stored_token_num + add_token_num, self._max_token_num)
27 | self._last_update_time = cur_time
28 |
29 | if self._stored_token_num < 1.0:
30 | return False
31 | self._stored_token_num -= 1.0
32 | return True
33 |
--------------------------------------------------------------------------------
/utils/request.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import asyncio
3 | import datetime
4 | import logging
5 | import os
6 | import pickle
7 | from typing import *
8 |
9 | import aiohttp
10 | import circuitbreaker
11 |
12 | import api.open_live
13 | import config
14 | import utils.async_io
15 |
16 | logger = logging.getLogger(__name__)
17 |
18 | COOKIE_JAR_PATH = os.path.join(config.DATA_PATH, 'cookie_jar.pickle')
19 |
20 | # 不带这堆头部有时候也能成功请求,但是带上后成功的概率更高
21 | BILIBILI_COMMON_HEADERS = {
22 | 'Origin': 'https://www.bilibili.com',
23 | 'Referer': 'https://www.bilibili.com/',
24 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)'
25 | ' Chrome/114.0.0.0 Safari/537.36'
26 | }
27 |
28 | http_session: Optional[aiohttp.ClientSession] = None
29 |
30 | _COMMON_SERVER_DISCOVERY_URLS = [
31 | 'https://api1.blive.chat/api/endpoints',
32 | 'https://api2.blive.chat/api/endpoints',
33 | ]
34 | _last_update_common_server_time: Optional[datetime.datetime] = None
35 | _common_server_base_urls = [
36 | 'https://api1.blive.chat',
37 | 'https://api2.blive.chat',
38 | ]
39 | _cur_common_server_base_url: Optional[str] = None
40 | _common_server_base_url_to_circuit_breaker: Dict[str, circuitbreaker.CircuitBreaker] = {}
41 |
42 |
43 | def init():
44 | try:
45 | cookie_jar = aiohttp.CookieJar()
46 | cookie_jar.load(COOKIE_JAR_PATH)
47 | except (OSError, pickle.PickleError):
48 | cookie_jar = None
49 |
50 | global http_session
51 | http_session = aiohttp.ClientSession(
52 | response_class=CustomClientResponse,
53 | timeout=aiohttp.ClientTimeout(total=10),
54 | cookie_jar=cookie_jar,
55 | )
56 |
57 | cfg = config.get_config()
58 | if not cfg.is_open_live_configured:
59 | _update_common_server_base_urls()
60 |
61 |
62 | async def shut_down():
63 | if http_session is not None:
64 | await http_session.close()
65 |
66 |
67 | class CustomClientResponse(aiohttp.ClientResponse):
68 | # 因为aiohttp的BUG,当底层连接断开时,_wait_released可能会抛出CancelledError,导致上层协程结束。这里改个错误类型
69 | async def _wait_released(self):
70 | try:
71 | return await super()._wait_released()
72 | except asyncio.CancelledError as e:
73 | raise aiohttp.ClientConnectionError('Connection released') from e
74 |
75 |
76 | def _update_common_server_base_urls():
77 | global _last_update_common_server_time
78 | cur_time = datetime.datetime.now()
79 | if (
80 | _last_update_common_server_time is not None
81 | and cur_time - _last_update_common_server_time < datetime.timedelta(minutes=3)
82 | ):
83 | return
84 | _last_update_common_server_time = cur_time
85 | utils.async_io.create_task_with_ref(_do_update_common_server_base_urls())
86 |
87 |
88 | async def _do_update_common_server_base_urls():
89 | global _last_update_common_server_time
90 | _last_update_common_server_time = datetime.datetime.now()
91 |
92 | async def request_get_urls(discovery_url):
93 | async with http_session.get(discovery_url) as res:
94 | res.raise_for_status()
95 | data = await res.json()
96 | return data['endpoints']
97 |
98 | common_server_base_urls = []
99 | futures = [
100 | asyncio.create_task(request_get_urls(url))
101 | for url in _COMMON_SERVER_DISCOVERY_URLS
102 | ]
103 | for future in asyncio.as_completed(futures):
104 | try:
105 | common_server_base_urls = await future
106 | break
107 | except Exception as e:
108 | logger.warning('Failed to discover common server endpoints from one source: %s', e)
109 | for future in futures:
110 | future.cancel()
111 | if not common_server_base_urls:
112 | logger.error('Failed to discover common server endpoints from any source')
113 | return
114 |
115 | # 按响应时间排序
116 | sorted_common_server_base_urls = []
117 | error_base_urls = []
118 |
119 | async def test_endpoint(base_url):
120 | try:
121 | url = base_url + '/api/ping'
122 | async with http_session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as res:
123 | res.raise_for_status()
124 | sorted_common_server_base_urls.append(base_url)
125 | except Exception: # noqa
126 | error_base_urls.append(base_url)
127 |
128 | await asyncio.gather(*(test_endpoint(base_url) for base_url in common_server_base_urls))
129 | sorted_common_server_base_urls.extend(error_base_urls)
130 |
131 | global _common_server_base_urls, _cur_common_server_base_url
132 | _common_server_base_urls = sorted_common_server_base_urls
133 | if _cur_common_server_base_url not in _common_server_base_urls:
134 | _cur_common_server_base_url = None
135 | logger.info('Found common server endpoints: %s', _common_server_base_urls)
136 |
137 |
138 | def get_common_server_base_url_and_circuit_breaker() -> Tuple[Optional[str], Optional[circuitbreaker.CircuitBreaker]]:
139 | _update_common_server_base_urls()
140 |
141 | global _cur_common_server_base_url
142 | if _cur_common_server_base_url is not None:
143 | breaker = _get_or_add_common_server_circuit_breaker(_cur_common_server_base_url)
144 | if breaker.state != circuitbreaker.STATE_OPEN:
145 | return _cur_common_server_base_url, breaker
146 | _cur_common_server_base_url = None
147 |
148 | # 找第一个未熔断的
149 | for base_url in _common_server_base_urls:
150 | breaker = _get_or_add_common_server_circuit_breaker(base_url)
151 | if breaker.state != circuitbreaker.STATE_OPEN:
152 | _cur_common_server_base_url = base_url
153 | logger.info('Switch common server endpoint to %s', _cur_common_server_base_url)
154 | return _cur_common_server_base_url, breaker
155 |
156 | return None, None
157 |
158 |
159 | def _get_or_add_common_server_circuit_breaker(base_url):
160 | breaker = _common_server_base_url_to_circuit_breaker.get(base_url, None)
161 | if breaker is None:
162 | breaker = _common_server_base_url_to_circuit_breaker[base_url] = circuitbreaker.CircuitBreaker(
163 | failure_threshold=3,
164 | recovery_timeout=60,
165 | expected_exception=api.open_live.TransportError,
166 | )
167 | return breaker
168 |
--------------------------------------------------------------------------------