├── .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 | ![OBS截图](./screenshots/obs.png) 6 | 7 | ![Chrome截图](./screenshots/chrome.png) 8 | 9 | ![样式生成器截图](./screenshots/stylegen.png) 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 | 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 | 23 | 24 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /frontend/src/components/ChatRenderer/AuthorChip.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /frontend/src/components/ChatRenderer/ImgShadow.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /frontend/src/components/ChatRenderer/MembershipItem.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /frontend/src/components/ChatRenderer/PaidMessage.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /frontend/src/components/ChatRenderer/TextMessage.vue: -------------------------------------------------------------------------------- 1 | 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 | 63 | 64 | 85 | 86 | 103 | -------------------------------------------------------------------------------- /frontend/src/layout/index.vue: -------------------------------------------------------------------------------- 1 | 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 | 19 | 20 | 25 | -------------------------------------------------------------------------------- /frontend/src/views/Home/TemplateSelect.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 138 | 139 | 223 | -------------------------------------------------------------------------------- /frontend/src/views/NotFound.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /frontend/src/views/Plugins.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 128 | 129 | 184 | -------------------------------------------------------------------------------- /frontend/src/views/StyleGenerator/FontSelect.vue: -------------------------------------------------------------------------------- 1 | 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 | --------------------------------------------------------------------------------