Skip to content
This repository was archived by the owner on Apr 24, 2025. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
c458259
Bump tornado from 6.4.1 to 6.4.2
dependabot[bot] Nov 22, 2024
faf272c
整理前端部分组件CSS
xfgryujk Nov 23, 2024
e27955b
原版YouTube样式放入@layer,自定义样式不用加!important了
xfgryujk Nov 23, 2024
3976aa7
优化样式生成器预览的性能
xfgryujk Nov 23, 2024
1244210
优化样式生成器界面
xfgryujk Nov 24, 2024
aae8bd2
SC固定栏支持滚动
xfgryujk Nov 24, 2024
45db27d
更新测试文本
xfgryujk Dec 2, 2024
b73608a
改善刷新页面导致连接数超出限制的问题
xfgryujk Dec 7, 2024
8538bc6
修复关闭页面时EndGame跨域
xfgryujk Dec 7, 2024
7f77fdd
接入开放平台服务器停止推送的消息,及时断开连接
xfgryujk Dec 8, 2024
3273fb4
开放平台接口修正礼物价格,web接口支持显示头像、修正上舰消息的单位
xfgryujk Dec 23, 2024
096da47
后端修复web接口弹幕解析错误
xfgryujk Dec 25, 2024
9bf8041
添加私货
xfgryujk Dec 29, 2024
87922e0
更新依赖
xfgryujk Jan 12, 2025
3b34346
支持显示at人、开放平台接口支持显示房管
xfgryujk Jan 19, 2025
0065a47
Gemini翻译改为OpenAI API兼容接口
xfgryujk Feb 24, 2025
069ff30
web接口添加礼物图标、勋章信息
xfgryujk Mar 9, 2025
2042a3f
前端添加自定义模板接口
xfgryujk Mar 23, 2025
79f070f
web接口换一个初始化房间的接口
xfgryujk Apr 3, 2025
7b91cba
后端添加获取自定义模板列表的接口
xfgryujk Apr 4, 2025
22d80e3
前端添加自定义模板配置
xfgryujk Apr 6, 2025
8c3dfa9
添加自定义模板缩略图
xfgryujk Apr 6, 2025
d2990ba
添加自定义模板SDK
xfgryujk Apr 13, 2025
491dbb4
前端richContent改名为contentParts
xfgryujk Apr 13, 2025
d24946f
前端渲染器消息添加更多字段
xfgryujk Apr 13, 2025
7ff3c9e
自定义模板SDK添加枚举和消息类型注解
xfgryujk Apr 13, 2025
9f7cdac
前端测试房间支持更多消息和字段
xfgryujk Apr 15, 2025
11234c8
添加测试消息
xfgryujk Apr 16, 2025
8dfb406
添加自定义模板帮助
xfgryujk Apr 19, 2025
edd3636
优化docker镜像,前端不存到卷里,默认去掉加载器URL
xfgryujk Apr 23, 2025
cbfcb01
更新版本号v1.10.0
xfgryujk Apr 23, 2025
8afb18b
仿微信风格样式生成器禁止选背景的不透明度
xfgryujk May 3, 2025
6ddf29e
自定义模板SDK升级到v1.0.1,支持注入OBS中的自定义CSS
xfgryujk May 3, 2025
f6bd831
修复有时候注入OBS中的自定义CSS失败的问题
xfgryujk May 4, 2025
540c3a6
样式生成器支持选择本地字体
xfgryujk May 4, 2025
bd3c4cd
增加字体下拉框的高度
xfgryujk May 7, 2025
4b533fd
前端修复一些消息的时序问题
xfgryujk May 7, 2025
f8f6741
样式生成器添加消息反向滚动的选项
xfgryujk May 9, 2025
cb60656
Bump tornado from 6.4.2 to 6.5.1
dependabot[bot] May 23, 2025
9d7143c
web接口修复-352错误
xfgryujk Jul 9, 2025
e39043b
屏蔽勋章等级上限改为120
xfgryujk Aug 11, 2025
1d53a12
更新版本号v1.10.1
xfgryujk Aug 14, 2025
0cc0090
修复web接口会显示两次上舰消息的问题
xfgryujk Nov 30, 2025
a435bc9
添加登录插件
xfgryujk Dec 7, 2025
844a57a
支持跨房弹幕
xfgryujk Dec 9, 2025
3d6d94e
前端减少首屏渲染时间
xfgryujk Feb 1, 2026
4e8521d
移除加载房间的随机延迟
xfgryujk Feb 1, 2026
aa697ac
支持屏蔽跨房弹幕
xfgryujk Feb 8, 2026
cdd9e92
SDK支持区分跨房弹幕
xfgryujk Feb 8, 2026
e2f0b0f
更新版本号v1.10.2
xfgryujk Feb 8, 2026
f8ac6c2
Bump tornado from 6.5.1 to 6.5.5
dependabot[bot] Mar 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ data/*
!data/custom_public/
data/custom_public/*
!data/custom_public/README.txt
!data/custom_public/templates/
data/custom_public/templates/*
!data/emoticons/
data/emoticons/*
!data/plugins/
Expand Down
11 changes: 5 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ RUN npm run build
# 准备后端
#

FROM python:3.12.7-bookworm
FROM python:3.12.10-bookworm
ARG BASE_PATH='/root/blivechat'
ARG EXT_DATA_PATH='/mnt/data'
WORKDIR "${BASE_PATH}"
Expand All @@ -30,18 +30,17 @@ RUN pip3 install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple -r req

# 数据目录
COPY . ./
RUN mkdir -p "${EXT_DATA_PATH}/frontend/dist" \
RUN sed 's/^host =.*$/host = 0.0.0.0/; s/^loader_url =.*$/loader_url =/' data/config.example.ini >> data/config.ini
RUN mkdir -p "${EXT_DATA_PATH}" \
&& mv data "${EXT_DATA_PATH}/data" \
&& ln -s "${EXT_DATA_PATH}/data" data \
&& mv log "${EXT_DATA_PATH}/log" \
&& ln -s "${EXT_DATA_PATH}/log" log \
&& ln -s "${EXT_DATA_PATH}/frontend/dist" frontend/dist
&& ln -s "${EXT_DATA_PATH}/log" log

# 编译好的前端
COPY --from=builder "${BASE_PATH}/frontend/dist" "${EXT_DATA_PATH}/frontend/dist/"
COPY --from=builder "${BASE_PATH}/frontend/dist" frontend/dist

# 运行
VOLUME "${EXT_DATA_PATH}"
EXPOSE 12450
ENTRYPOINT ["python3", "main.py"]
CMD ["--host", "0.0.0.0", "--port", "12450"]
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@
## 特性

* 兼容YouTube直播评论栏的样式
* 高亮舰队、 ~~房管~~ 、主播的用户名
* 高亮舰队、房管、主播的用户名
* 自带两种样式生成器,经典YouTube风格和仿微信风格
* 支持屏蔽弹幕、合并礼物等设置
* 支持前端直连B站服务器或者通过后端转发
* 支持自动翻译弹幕、醒目留言到日语,可以在后台配置翻译目标语言
* 支持标注打赏用户名的读音,可选拼音或日文假名
* 支持配置自定义表情,不需要开通B站官方表情
* 支持插件开发
* 支持[自定义HTML模板](https://github.com/xfgryujk/blivechat/wiki/%E8%87%AA%E5%AE%9A%E4%B9%89HTML%E6%A8%A1%E6%9D%BF)
* 支持[插件开发](https://github.com/xfgryujk/blivechat/wiki/%E6%8F%92%E4%BB%B6%E7%B3%BB%E7%BB%9F)

## 使用方法

Expand Down Expand Up @@ -105,7 +106,7 @@

服务器配置文件在`data/config.ini`,可以配置数据库和允许自动翻译等,编辑后要重启生效

**自建服务器时强烈建议不使用加载器**,否则可能因为各种原因加载不出来
**自建服务器时注意要删除loader_url配置**,否则加载不了房间页面

## 常用链接

Expand Down
90 changes: 60 additions & 30 deletions api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def make_text_message_data(
content_type_params: list = None,
uid: str = '',
medal_name: str = '',
is_mirror: bool = False,
):
# 为了节省带宽用list而不是dict
return [
Expand Down Expand Up @@ -113,6 +114,8 @@ def make_text_message_data(
uid,
# 17: medalName
medal_name,
# 18: isMirror
1 if is_mirror else 0,
]


Expand Down Expand Up @@ -327,34 +330,45 @@ async def _send_test_message(self):


class RoomInfoHandler(api.base.ApiHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._user_agent = ''
self._http_session: Optional[aiohttp.ClientSession] = None

async def get(self):
room_id = int(self.get_query_argument('roomId'))
logger.info('client=%s getting room info, room=%d', self.request.remote_ip, room_id)

(room_id, owner_uid), (host_server_list, host_server_token), buvid = await asyncio.gather(
self._get_room_info(room_id),
self._get_server_host_list_and_token(room_id),
self._get_buvid()
)

# 缓存1分钟
self.set_header('Cache-Control', 'private, max-age=60')
self.write({
'roomId': room_id,
'ownerUid': owner_uid,
'hostServerList': host_server_list,
'hostServerToken': host_server_token,
# 虽然没什么用但还是加上比较保险
'buvid': buvid,
})

@staticmethod
async def _get_room_info(room_id) -> Tuple[int, int]:
self._user_agent = self.request.headers.get('User-Agent', '')
# 因为UA会变,所以不能用公共的session了
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as http_session:
self._http_session = http_session

# 要先初始化buvid才能进行后续请求,不然会-352
buvid = await self._get_buvid()
(room_id, owner_uid), (host_server_list, host_server_token) = await asyncio.gather(
self._get_room_info(room_id),
self._get_server_host_list_and_token(room_id),
)

# 缓存1分钟
self.set_header('Cache-Control', 'private, max-age=60')
self.write({
'roomId': room_id,
'ownerUid': owner_uid,
'hostServerList': host_server_list,
'hostServerToken': host_server_token,
# 虽然没什么用但还是加上比较保险
'buvid': buvid,
})

async def _get_room_info(self, room_id) -> Tuple[int, int]:
try:
async with utils.request.http_session.get(
async with self._http_session.get(
dm_web_cli.ROOM_INIT_URL,
headers={
**utils.request.BILIBILI_COMMON_HEADERS,
'User-Agent': self._user_agent,
'Origin': 'https://live.bilibili.com',
'Referer': f'https://live.bilibili.com/{room_id}'
},
Expand All @@ -375,21 +389,29 @@ async def _get_room_info(room_id) -> Tuple[int, int]:
logger.warning('room=%d _get_room_info failed: %s', room_id, data['message'])
return room_id, 0

room_info = data['data']['room_info']
return room_info['room_id'], room_info['uid']
data = data['data']
return data['room_id'], data['uid']

async def _get_server_host_list_and_token(self, room_id) -> Tuple[dict, Optional[str]]:
wbi_signer = dm_web_cli._get_wbi_signer(self._http_session) # noqa
if wbi_signer.need_refresh_wbi_key:
await wbi_signer.refresh_wbi_key()
# 如果没刷新成功先用旧的key
if wbi_signer.wbi_key == '':
logger.warning('room %d _get_server_host_list failed: no wbi key', room_id)
return dm_web_cli.DEFAULT_DANMAKU_SERVER_LIST, None

try:
async with utils.request.http_session.get(
async with self._http_session.get(
dm_web_cli.DANMAKU_SERVER_CONF_URL,
headers={
# token会对UA签名,要使用和客户端一样的UA
'User-Agent': self.request.headers.get('User-Agent', '')
'User-Agent': self._user_agent
},
params={
params=wbi_signer.add_wbi_sign({
'id': room_id,
'type': 0
}
})
) as res:
if res.status != 200:
logger.warning('room %d _get_server_host_list failed: %d %s', room_id,
Expand All @@ -401,6 +423,9 @@ async def _get_server_host_list_and_token(self, room_id) -> Tuple[dict, Optional
return dm_web_cli.DEFAULT_DANMAKU_SERVER_LIST, None

if data['code'] != 0:
if data['code'] == -352:
# wbi签名错误
wbi_signer.reset()
logger.warning('room %d _get_server_host_list failed: %s', room_id, data['message'])
return dm_web_cli.DEFAULT_DANMAKU_SERVER_LIST, None

Expand All @@ -419,15 +444,20 @@ async def _get_buvid(self):
return buvid

try:
async with utils.request.http_session.get(dm_web_cli.BUVID_INIT_URL):
async with self._http_session.get(
dm_web_cli.BUVID_INIT_URL,
headers={
# 要使用和后续请求一样的UA
'User-Agent': self._user_agent
}
):
pass
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
pass
return self._do_get_buvid()

@staticmethod
def _do_get_buvid():
cookies = utils.request.http_session.cookie_jar.filter_cookies(yarl.URL(dm_web_cli.BUVID_INIT_URL))
def _do_get_buvid(self):
cookies = self._http_session.cookie_jar.filter_cookies(yarl.URL(dm_web_cli.BUVID_INIT_URL))
buvid_cookie = cookies.get('buvid3', None)
if buvid_cookie is None:
return ''
Expand Down
77 changes: 77 additions & 0 deletions api/main.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# -*- coding: utf-8 -*-
import asyncio
import hashlib
import json
import logging
import os

import cachetools
import tornado.web
import yarl

import api.base
import config
Expand All @@ -15,6 +18,10 @@
EMOTICON_UPLOAD_PATH = os.path.join(config.DATA_PATH, 'emoticons')
EMOTICON_BASE_URL = '/emoticons'
CUSTOM_PUBLIC_PATH = os.path.join(config.DATA_PATH, 'custom_public')
TEMPLATE_PATH = os.path.join(CUSTOM_PUBLIC_PATH, 'templates')
TEMPLATE_BASE_URL = '/custom_public/templates'

_templates_cache = cachetools.TTLCache(1, 10)


class StaticHandler(tornado.web.StaticFileHandler):
Expand Down Expand Up @@ -100,6 +107,75 @@ def _save_file(body, client):
return f'{EMOTICON_BASE_URL}/{filename}'


class TemplatesHandler(api.base.ApiHandler):
async def get(self):
templates = _templates_cache.get('templates', None)
if templates is None:
templates = await asyncio.get_running_loop().run_in_executor(None, self._get_templates)
_templates_cache['templates'] = templates

self.set_header('Cache-Control', 'private, max-age=10')
self.write({'templates': templates})

def _get_templates(self):
template_ids = []
try:
with os.scandir(TEMPLATE_PATH) as it:
for entry in it:
if entry.is_dir() and os.path.isfile(os.path.join(entry.path, 'template.json')):
template_ids.append(entry.name)
except OSError:
logger.exception('Failed to discover templates:')
return []
if not template_ids:
return []

templates = []
for template_id in template_ids:
try:
template = self._load_template_config(template_id)
templates.append(template)
except (OSError, json.JSONDecodeError, TypeError, ValueError):
logger.exception('template_id=%s failed to load config:', template_id)
return templates

@staticmethod
def _load_template_config(template_id):
config_path = os.path.join(TEMPLATE_PATH, template_id, 'template.json')
with open(config_path, encoding='utf-8') as f:
cfg = json.load(f)
if not isinstance(cfg, dict):
raise TypeError(f'Config type error, type={type(cfg)}')

# 相对于模板目录
base_url = yarl.URL(f'{TEMPLATE_BASE_URL}/{template_id}/')

def ensure_abs_url(url_str_):
url_ = yarl.URL(url_str_)
if not url_.absolute:
url_ = base_url.join(url_)
url_str_ = str(url_)
return url_str_

thumbnail_url_str = str(cfg.get('thumbnail', ''))
if thumbnail_url_str != '':
thumbnail_url_str = ensure_abs_url(thumbnail_url_str)

url_str = str(cfg.get('url', ''))
url_str = ensure_abs_url(url_str)

template = {
'id': template_id,
'name': str(cfg.get('name', '')),
'version': str(cfg.get('version', '')),
'author': str(cfg.get('author', '')),
'description': str(cfg.get('description', '')),
'thumbnail': thumbnail_url_str,
'url': url_str,
}
return template


class NoCacheStaticFileHandler(tornado.web.StaticFileHandler):
def set_extra_headers(self, path):
self.set_header('Cache-Control', 'no-cache')
Expand All @@ -110,6 +186,7 @@ def set_extra_headers(self, path):
(r'/api/endpoints', ServiceDiscoveryHandler),
(r'/api/ping', PingHandler),
(r'/api/emoticon', UploadEmoticonHandler),
(r'/api/templates', TemplatesHandler),
]
# 通配的放在最后
LAST_ROUTES = [
Expand Down
11 changes: 11 additions & 0 deletions api/open_live.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,17 @@ class EndGamePublicHandler(_PublicHandlerBase):
_OPEN_LIVE_URL = END_GAME_OPEN_LIVE_URL
_COMMON_SERVER_URL = END_GAME_COMMON_SERVER_URL

def set_default_headers(self):
super().set_default_headers()

if self._headers.get('Access-Control-Allow-Origin', None) is None:
return

# 前端sendBeacon跨域发送JSON时会带凭证
self.set_header('Access-Control-Allow-Credentials', 'true')
self.set_header('Access-Control-Allow-Methods', 'POST')
self.set_header('Access-Control-Allow-Headers', 'Content-Type')


class EndGamePrivateHandler(_PrivateHandlerBase):
_OPEN_LIVE_URL = END_GAME_OPEN_LIVE_URL
Expand Down
2 changes: 1 addition & 1 deletion blcsdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
__version__ = '1.0.0'
__version__ = '1.0.1'

from .handlers import *
from .client import *
Expand Down
4 changes: 4 additions & 0 deletions blcsdk/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ class AddTextMsg:
"""用户Open ID或ID"""
medal_name: str = ''
"""勋章名"""
is_mirror: bool = False
"""是否跨房弹幕,v1.10.2添加"""

@classmethod
def from_command(cls, data: list):
Expand All @@ -229,6 +231,7 @@ def from_command(cls, data: list):
if content_type == ContentType.EMOTICON:
content_type_params = {'url': content_type_params[0]}

data_len = len(data)
return cls(
avatar_url=data[0],
timestamp=data[1],
Expand All @@ -247,6 +250,7 @@ def from_command(cls, data: list):
content_type_params=content_type_params,
uid=data[16],
medal_name=data[17],
is_mirror=bool(data[18]) if data_len > 18 else False,
)


Expand Down
Loading