release: v1.0.0
7
.flake8
Normal file
@ -0,0 +1,7 @@
|
||||
[flake8]
|
||||
ignore = D203, W504
|
||||
exclude =
|
||||
__*,
|
||||
.*,
|
||||
build,
|
||||
dist,
|
8
MANIFEST.in
Normal file
@ -0,0 +1,8 @@
|
||||
# Include the README
|
||||
include README.md
|
||||
|
||||
# Include the license file
|
||||
include LICENSE
|
||||
|
||||
# Include all package data
|
||||
graft src/blrec/data
|
195
README.md
Normal file
@ -0,0 +1,195 @@
|
||||
# Bilibili Live Streaming Recorder (blrec)
|
||||
|
||||
这是一个前后端分离的 B 站直播录制工具。前端使用了响应式设计,可适应不同的屏幕尺寸;后端是用 Python 写的,可以跨平台运行。
|
||||
|
||||
这个工具是自动化的,会自动完成直播的录制, 在出现未处理异常时会发送通知,空间不足能够自动回收空间,还有详细日志记录,因此可以长期无人值守运行在服务器上。
|
||||
|
||||
## 屏幕截图
|
||||
|
||||
![webapp](https://user-images.githubusercontent.com/33854576/128959800-451d03e7-c9f9-4732-ac90-97fdb6b88972.png)
|
||||
|
||||
![terminal](https://user-images.githubusercontent.com/33854576/128959819-70d72937-65da-4c15-b61c-d2da65bf42be.png)
|
||||
|
||||
## 功能
|
||||
|
||||
- 自动完成直播录制
|
||||
- 同步保存弹幕
|
||||
- 自动修复时间戳问题:跳变、反跳等。
|
||||
- 直播流参数改变自动分割文件,避免出现花屏等问题。
|
||||
- 流中断自动拼接且支持 **无缝** 拼接,不会因网络中断而使录播文件片段化。
|
||||
- `flv` 文件注入关键帧等元数据,定位播放和拖进度条不会卡顿。
|
||||
- 可选录制的画质
|
||||
- 可自定义文件保存路径和文件名
|
||||
- 支持按文件大小或时长分割文件
|
||||
- 支持转换 `flv` 为 `mp4` 格式(需要安装 `ffmpeg`)
|
||||
- 硬盘空间检测并支持空间不足自动删除旧录播文件。
|
||||
- 事件通知(支持邮箱、`ServerChan`、`pushplus`)
|
||||
- `Webhook`(可配合 `REST API` 实现录制控制,录制完成后压制、上传等自定义需求)
|
||||
|
||||
## 先决条件
|
||||
|
||||
Python 3.8+
|
||||
ffmpeg (如果需要转换 flv 为 mp4)
|
||||
|
||||
## 安装
|
||||
|
||||
pip install blrec
|
||||
|
||||
用到的一些库需要 C 编译器,Windows 没 C 编译器会安装出错,使用以下方法安装已编译好的库。
|
||||
|
||||
pip install -r windows-requirements.txt
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 使用默认设置文件和保存位置
|
||||
|
||||
在命令行终端里敲入 `blrec` 并回车运行,然后在浏览器访问 `http://localhost:2233`。
|
||||
|
||||
设置文件为 `toml` 文件,默认位置在 `~/.blrec/settings.toml`。默认录播文件保存位置为当前工作目录 `.`。
|
||||
|
||||
### 指定设置文件和保存位置
|
||||
|
||||
`blrec -c path/to/settings.toml -o dirpath/to/save/files`
|
||||
|
||||
如果指定的设置文件不存在会自动创建。通过命令行参数指定保存位置会覆盖掉设置文件的设置。
|
||||
|
||||
### 绑定主机和端口
|
||||
|
||||
`blrec --host 0.0.0.0 --port 8000`
|
||||
|
||||
默认主机 `127.0.0.1`,默认端口 `2233`.
|
||||
|
||||
### 安全保障
|
||||
|
||||
`blrec --key-file path/to/key-file --cert-file path/to/cert-file --api-key ********`
|
||||
|
||||
可以使用 `api key` 防止被恶意访问和泄漏设置里的敏感信息
|
||||
|
||||
## 作为 ASGI 应用运行
|
||||
|
||||
uvicorn blrec.web:app
|
||||
|
||||
或者
|
||||
|
||||
hypercorn blrec.web:app
|
||||
|
||||
作为 ASGI 应用运行,参数通过环境变量指定。
|
||||
|
||||
- `config` 指定设置文件
|
||||
- `out_dir` 指定保存位置
|
||||
- `api_key` 指定 `api key`
|
||||
|
||||
### bash
|
||||
|
||||
config=path/to/settings.toml out_dir=path/to/dir api_key=******** uvicorn blrec.web:app --host 0.0.0.0 --port 8000
|
||||
|
||||
### cmd
|
||||
|
||||
set config=D:\\path\\to\\config.toml & set out_dir=D:\\path\\to\\dir & set api_key=******** uvicorn blrec.web:app --host 0.0.0.0 --port 8000
|
||||
|
||||
## Webhook
|
||||
|
||||
程序在运行过程中会触发一些事件,如果是支持 `webhook` 的事件,就会给所设置的 `webhook` 网络地址发送 POST 请求。
|
||||
|
||||
关于支持的事件和 `POST` 请求所发送的数据,详见 wiki。
|
||||
|
||||
## REST API
|
||||
|
||||
后端 `web` 框架用的是 `FastApi` , 要查看自动生成的交互式 `API` 文档,访问 `http://localhost:2233/docs` (默认主机和端口绑定)。
|
||||
|
||||
## Progressive Web App(PWA)
|
||||
|
||||
前端其实是一个渐进式网络应用,可以通过地址栏右侧的图标安装,然后像原生应用一样从桌面启动运行。
|
||||
|
||||
**注意:PWA 要在本地访问或者在 `https` 下才支持。**
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 如何终止程序?
|
||||
|
||||
`ctrl + c`
|
||||
|
||||
### 如何更新程序?
|
||||
|
||||
`pip install blrec --upgrade`
|
||||
|
||||
### 设置修改后什么时候生效?
|
||||
|
||||
设置修改后基本上是即时生效的,但是对于正在录制的任务有些例外。
|
||||
|
||||
对保存位置或路径模板的修改,不影响当前正在录制的文件,要下一次创建文件才会生效。
|
||||
|
||||
对画质的修改,要下一次获取直播流 `url` 才会生效。如果想立即生效,停止录制再重新开始录制就可以。
|
||||
|
||||
### 什么是流拼接和无缝拼接?
|
||||
|
||||
流拼接就是在录制的过程中出现网络中断时从中断的位置接续上去,以避免录播文件片段化。
|
||||
而无缝拼接就是把中断处前后重复的数据丢掉然后再接续上,就好像没中断过一样,不会丢失数据。
|
||||
|
||||
**注意:只有网络中断后在短时间内重新连接上,中断处前后有重复数据才有可能进行无缝拼接。**
|
||||
|
||||
### 怎么知道是否出现了流中断?
|
||||
|
||||
如果流出现了中断,不但日志里有记录,程序还会把流中断的详细信息写入到视频文件的元数据里。
|
||||
|
||||
可以用 `ffprob` 查看视频文件元数据的 `Comment` 字段。
|
||||
|
||||
有些播放器也可以查看。`VLC` 可以按 `ctrl + i` 后查看注释;`PotPlayer` 可以按两次 tab 键查看评论(mp4 没问题,但是 flv 会因为内容过长而不显示~)。
|
||||
|
||||
mp4 还在非无缝拼接的位置添加了章节标记,支持章节标记的播放器应该可以看到。
|
||||
|
||||
### 如何避免流中断、漏录?
|
||||
|
||||
cpu 使用率过高、网络带宽不足或不稳定、硬盘读写慢都会导致流中断,如果不能及时恢复就无法进行无缝拼接进而导致漏录了。所以,只要运行程序的机器配置足够高且网络带宽充足稳定就可以有效避免录制端导致的流中断、漏录。
|
||||
|
||||
主播网络或 B 站服务器问题导致的流中断、漏录是无法避免的~
|
||||
|
||||
### 为什么录播文件的时长小于直播持续时间?
|
||||
|
||||
- 开播后推流有延迟
|
||||
- 录制过程出现流中断并且未能无缝拼接,漏录了~
|
||||
- 主播网络不稳定,流中出现了时间戳跳变、反跳,经过修正后时长可能会变短。
|
||||
|
||||
### 为什么录制的画质和所设置的画质不符合?
|
||||
|
||||
所设置的画质在开播后不一定存在,如果不存在就会以原画代替。
|
||||
|
||||
### 为什么按文件大小或时长分割的文件比设置的值小或短?
|
||||
|
||||
分割位置必须在关键帧处才不会丢帧。为了确保文件不会超过指定的限制,会在关键帧将超过所设置的值的前一个关键帧处进行分割,所以文件的大小或时长要比所设置的值小一些。
|
||||
|
||||
### 为什么没有设置分割文件还是出现了多个录播文件?
|
||||
|
||||
为了避免录播文件出现花屏等问题,在直播流参数改变(主播修改分辨率、码率等)时就会自动分割文件。
|
||||
|
||||
有时则是主播网络很不稳定造成多次下播上播导致的。
|
||||
|
||||
### 为什么设置了转换为 `mp4` 格式,但却没有进行转换?
|
||||
|
||||
请确保正确安装了 `ffmpeg`,可以在终端里正常使用 `ffmpeg`。
|
||||
|
||||
### 怎样才算是旧录播文件?
|
||||
|
||||
创建时间超过 24 小时才会被当成旧录播文件在空间不足时被删除。
|
||||
|
||||
### 空间不足时是怎样删除旧录播文件的?
|
||||
|
||||
删除文件是按创建时间的先后进行的,最早创建的最先被删除,直到可用空间不少于所设置的阈值为止。
|
||||
|
||||
### 支持录制付费直播吗?
|
||||
|
||||
没试过~ 可以尝试在网络请求设置里填写已付费账号登录后的 Cookie
|
||||
|
||||
### 为什么要重复造轮子?
|
||||
|
||||
因为现有工具大多都是直接下载流或调用 ffmpeg 进行录制,不能很好地解决漏录、数据损坏、录播文件片段化等录播问题。尤其录制的直播很不稳定,结果很不尽如人意。
|
||||
|
||||
---
|
||||
|
||||
## 赞助 & 支持
|
||||
|
||||
如果觉得这个工具好用,对你有所帮助,可以投喂支持亿下哦~
|
||||
|
||||
投喂赞助 ☞ <https://afdian.net/@acgnhiki>
|
19
mypy.ini
Normal file
@ -0,0 +1,19 @@
|
||||
[mypy]
|
||||
plugins = pydantic.mypy
|
||||
|
||||
follow_imports = silent
|
||||
show_column_numbers = True
|
||||
ignore_missing_imports = True
|
||||
warn_unused_ignores = True
|
||||
warn_redundant_casts = True
|
||||
check_untyped_defs = True
|
||||
disallow_any_generics = True
|
||||
|
||||
# for strict mypy: (this is the tricky one :-))
|
||||
disallow_untyped_defs = True
|
||||
|
||||
[pydantic-mypy]
|
||||
init_forbid_extra = True
|
||||
init_typed = True
|
||||
warn_required_dynamic_aliases = True
|
||||
warn_untyped_fields = True
|
6
pyproject.toml
Normal file
@ -0,0 +1,6 @@
|
||||
[build-system]
|
||||
requires = [
|
||||
"setuptools >= 57.0.0, < 58.0.0",
|
||||
"wheel >= 0.37, < 0.38.0",
|
||||
]
|
||||
build-backend = "setuptools.build_meta"
|
79
setup.cfg
Normal file
@ -0,0 +1,79 @@
|
||||
[metadata]
|
||||
name = blrec
|
||||
version = attr: blrec.__version__
|
||||
description = Bilibili Live Streaming Recorder
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
keywords = bilibili, live, danmaku, recorder
|
||||
license = GPLv3
|
||||
license_file = LICENSE
|
||||
author = acgnhiki
|
||||
author_email = acgnhiki@outlook.com
|
||||
url = https://github.com/acgnhiki/blrec
|
||||
classifiers =
|
||||
Development Status :: 5 - Production/Stable
|
||||
Environment :: Console
|
||||
Environment :: Web Environment
|
||||
Intended Audience :: Developers
|
||||
Intended Audience :: End Users/Desktop
|
||||
License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
||||
Topic :: Internet
|
||||
Topic :: Multimedia :: Video
|
||||
Operating System :: OS Independent
|
||||
Programming Language :: Python :: 3 :: Only
|
||||
Programming Language :: Python :: 3
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
Programming Language :: Python :: 3.10
|
||||
Programming Language :: Python :: Implementation :: CPython
|
||||
Typing :: Typed
|
||||
|
||||
[options]
|
||||
packages = find:
|
||||
package_dir =
|
||||
=src
|
||||
include_package_data = True
|
||||
python_requires = >= 3.8
|
||||
install_requires =
|
||||
typing-extensions >= 3.10.0.0
|
||||
fastapi >= 0.65.2, < 0.66.0
|
||||
email_validator >=1.1.2, < 2.0.0
|
||||
typer >= 0.3.2, < 0.4.0
|
||||
aiohttp >= 3.6.2, < 3.7.0
|
||||
requests >= 2.24.0, < 2.25.0
|
||||
aiofiles >= 0.5.0, < 0.6.0
|
||||
tenacity >= 6.2.0, < 6.3.0
|
||||
colorama >= 0.4.3, < 0.5.0
|
||||
humanize >= 3.2.0, < 3.3.0
|
||||
tqdm >= 4.56.2, < 4.57.0
|
||||
attrs >= 21.2.0, < 21.3.0
|
||||
lxml >= 4.6.2, < 4.7.0
|
||||
toml >= 0.10.2, < 0.11.0
|
||||
psutil >= 5.8.0, < 5.9.0
|
||||
rx >= 3.1.1, < 3.2.0
|
||||
bitarray >= 2.2.5, < 2.3.0
|
||||
uvicorn[standard] >=0.12.0, < 0.15.0
|
||||
|
||||
[options.extras_require]
|
||||
dev =
|
||||
flake8 >= 3.9.2, < 4.0.0
|
||||
mypy >= 0.910, < 1.000
|
||||
|
||||
setuptools >= 57.0.0, < 58.0.0
|
||||
wheel >= 0.37, < 0.38.0
|
||||
build >= 0.5.1, < 0.6.0
|
||||
|
||||
# missing stub packages
|
||||
types-requests >= 2.25.1, < 2.26.0
|
||||
types-aiofiles >= 0.1.7, < 0.2.0
|
||||
types-toml >= 0.1.3, < 0.2.0
|
||||
types-setuptools >= 57.0.0, < 58.0.0
|
||||
|
||||
speedups = aiohttp[speedups] >= 3.6.2, < 3.7.0
|
||||
|
||||
[options.packages.find]
|
||||
where = src
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
blrec = blrec.cli.main:main
|
3
src/blrec/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
__version__ = '1.0.0'
|
||||
__prog__ = 'blrec'
|
5
src/blrec/__main__.py
Normal file
@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
from .cli.main import main
|
||||
|
||||
sys.exit(main())
|
298
src/blrec/application.py
Normal file
@ -0,0 +1,298 @@
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Iterator, List, Optional
|
||||
|
||||
import attr
|
||||
import psutil
|
||||
|
||||
from . import __prog__, __version__
|
||||
from .disk_space import SpaceMonitor, SpaceReclaimer
|
||||
from .bili.helpers import ensure_room_id
|
||||
from .task import (
|
||||
RecordTaskManager,
|
||||
TaskParam,
|
||||
TaskData,
|
||||
FileDetail,
|
||||
)
|
||||
from .exception import ExistsError, ExceptionHandler
|
||||
from .event.event_submitters import SpaceEventSubmitter
|
||||
from .setting import (
|
||||
SettingsManager,
|
||||
Settings,
|
||||
SettingsIn,
|
||||
SettingsOut,
|
||||
KeySetOfSettings,
|
||||
TaskOptions,
|
||||
)
|
||||
from .notification import (
|
||||
EmailNotifier,
|
||||
ServerchanNotifier,
|
||||
PushplusNotifier,
|
||||
)
|
||||
from .webhook import WebHookEmitter
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class AppInfo:
|
||||
name: str
|
||||
version: str
|
||||
pid: int
|
||||
ppid: int
|
||||
create_time: float
|
||||
cwd: str
|
||||
exe: str
|
||||
cmdline: List[str]
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class AppStatus:
|
||||
cpu_percent: float
|
||||
memory_percent: float
|
||||
num_threads: int
|
||||
|
||||
|
||||
class Application:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._out_dir = settings.output.out_dir
|
||||
self._settings_manager = SettingsManager(self, settings)
|
||||
self._task_manager = RecordTaskManager(self._settings_manager)
|
||||
|
||||
@property
|
||||
def info(self) -> AppInfo:
|
||||
p = psutil.Process(os.getpid())
|
||||
with p.oneshot():
|
||||
return AppInfo(
|
||||
name=__prog__,
|
||||
version=__version__,
|
||||
pid=p.pid,
|
||||
ppid=p.ppid(),
|
||||
create_time=p.create_time(),
|
||||
cwd=p.cwd(),
|
||||
exe=p.exe(),
|
||||
cmdline=p.cmdline(),
|
||||
)
|
||||
|
||||
@property
|
||||
def status(self) -> AppStatus:
|
||||
p = psutil.Process(os.getpid())
|
||||
with p.oneshot():
|
||||
return AppStatus(
|
||||
cpu_percent=p.cpu_percent(),
|
||||
memory_percent=p.memory_percent(),
|
||||
num_threads=p.num_threads(),
|
||||
)
|
||||
|
||||
def run(self) -> None:
|
||||
asyncio.run(self._run())
|
||||
|
||||
async def _run(self) -> None:
|
||||
self._loop = asyncio.get_running_loop()
|
||||
|
||||
await self.launch()
|
||||
try:
|
||||
self._interrupt_event = asyncio.Event()
|
||||
await self._interrupt_event.wait()
|
||||
finally:
|
||||
await self.exit()
|
||||
|
||||
async def launch(self) -> None:
|
||||
self._setup()
|
||||
await self._task_manager.load_all_tasks()
|
||||
logger.info('Launched Application')
|
||||
|
||||
async def exit(self) -> None:
|
||||
await self._exit()
|
||||
logger.info('Exited Application')
|
||||
|
||||
async def abort(self) -> None:
|
||||
await self._exit(force=True)
|
||||
logger.info('Aborted Application')
|
||||
|
||||
async def _exit(self, force: bool = False) -> None:
|
||||
await self._task_manager.stop_all_tasks(force=force)
|
||||
await self._task_manager.destroy_all_tasks()
|
||||
self._destroy()
|
||||
|
||||
async def restart(self) -> None:
|
||||
logger.info('Restarting Application...')
|
||||
await self.exit()
|
||||
await self.launch()
|
||||
|
||||
def has_task(self, room_id: int) -> bool:
|
||||
return self._task_manager.has_task(room_id)
|
||||
|
||||
async def add_task(self, room_id: int) -> int:
|
||||
room_id = await ensure_room_id(room_id)
|
||||
|
||||
if self._settings_manager.has_task_settings(room_id):
|
||||
raise ExistsError(
|
||||
f"a task for the room {room_id} is already existed"
|
||||
)
|
||||
|
||||
settings = await self._settings_manager.add_task_settings(room_id)
|
||||
await self._task_manager.add_task(settings)
|
||||
|
||||
return room_id
|
||||
|
||||
async def remove_task(self, room_id: int) -> None:
|
||||
await self._task_manager.remove_task(room_id)
|
||||
await self._settings_manager.remove_task_settings(room_id)
|
||||
|
||||
async def remove_all_tasks(self) -> None:
|
||||
await self._task_manager.remove_all_tasks()
|
||||
await self._settings_manager.remove_all_task_settings()
|
||||
|
||||
async def start_task(self, room_id: int) -> None:
|
||||
await self._task_manager.start_task(room_id)
|
||||
await self._settings_manager.mark_task_enabled(room_id)
|
||||
|
||||
async def stop_task(self, room_id: int, force: bool = False) -> None:
|
||||
await self._task_manager.stop_task(room_id, force)
|
||||
await self._settings_manager.mark_task_disabled(room_id)
|
||||
|
||||
async def start_all_tasks(self) -> None:
|
||||
await self._task_manager.start_all_tasks()
|
||||
await self._settings_manager.mark_all_tasks_enabled()
|
||||
|
||||
async def stop_all_tasks(self, force: bool = False) -> None:
|
||||
await self._task_manager.stop_all_tasks(force)
|
||||
await self._settings_manager.mark_all_tasks_disabled()
|
||||
|
||||
async def enable_task_recorder(self, room_id: int) -> None:
|
||||
await self._task_manager.enable_task_recorder(room_id)
|
||||
await self._settings_manager.mark_task_recorder_enabled(room_id)
|
||||
|
||||
async def disable_task_recorder(
|
||||
self, room_id: int, force: bool = False
|
||||
) -> None:
|
||||
await self._task_manager.disable_task_recorder(room_id, force)
|
||||
await self._settings_manager.mark_task_recorder_disabled(room_id)
|
||||
|
||||
async def enable_all_task_recorders(self) -> None:
|
||||
await self._task_manager.enable_all_task_recorders()
|
||||
await self._settings_manager.mark_all_task_recorders_enabled()
|
||||
|
||||
async def disable_all_task_recorders(self, force: bool = False) -> None:
|
||||
await self._task_manager.disable_all_task_recorders(force)
|
||||
await self._settings_manager.mark_all_task_recorders_disabled()
|
||||
|
||||
def get_task_data(self, room_id: int) -> TaskData:
|
||||
return self._task_manager.get_task_data(room_id)
|
||||
|
||||
def get_all_task_data(self) -> Iterator[TaskData]:
|
||||
yield from self._task_manager.get_all_task_data()
|
||||
|
||||
def get_task_param(self, room_id: int) -> TaskParam:
|
||||
return self._task_manager.get_task_param(room_id)
|
||||
|
||||
def get_task_file_details(self, room_id: int) -> Iterator[FileDetail]:
|
||||
yield from self._task_manager.get_task_file_details(room_id)
|
||||
|
||||
async def update_task_info(self, room_id: int) -> None:
|
||||
await self._task_manager.update_task_info(room_id)
|
||||
|
||||
async def update_all_task_infos(self) -> None:
|
||||
await self._task_manager.update_all_task_infos()
|
||||
|
||||
def get_settings(
|
||||
self,
|
||||
include: Optional[KeySetOfSettings] = None,
|
||||
exclude: Optional[KeySetOfSettings] = None,
|
||||
) -> SettingsOut:
|
||||
return self._settings_manager.get_settings(include, exclude)
|
||||
|
||||
async def change_settings(self, settings: SettingsIn) -> SettingsOut:
|
||||
return await self._settings_manager.change_settings(settings)
|
||||
|
||||
def get_task_options(self, room_id: int) -> TaskOptions:
|
||||
return self._settings_manager.get_task_options(room_id)
|
||||
|
||||
async def change_task_options(
|
||||
self, room_id: int, options: TaskOptions
|
||||
) -> TaskOptions:
|
||||
return await self._settings_manager.change_task_options(
|
||||
room_id, options
|
||||
)
|
||||
|
||||
def _setup(self) -> None:
|
||||
self._setup_logger()
|
||||
self._setup_exception_handler()
|
||||
self._setup_space_monitor()
|
||||
self._setup_space_event_submitter()
|
||||
self._setup_space_reclaimer()
|
||||
self._setup_notifiers()
|
||||
self._setup_webhooks()
|
||||
|
||||
def _setup_logger(self) -> None:
|
||||
self._settings_manager.apply_logging_settings()
|
||||
|
||||
def _setup_exception_handler(self) -> None:
|
||||
self._exception_handler = ExceptionHandler()
|
||||
self._exception_handler.enable()
|
||||
|
||||
def _setup_space_monitor(self) -> None:
|
||||
self._space_monitor = SpaceMonitor(self._out_dir)
|
||||
self._settings_manager.apply_space_monitor_settings()
|
||||
self._space_monitor.enable()
|
||||
|
||||
def _setup_space_event_submitter(self) -> None:
|
||||
self._space_event_submitter = SpaceEventSubmitter(self._space_monitor)
|
||||
|
||||
def _setup_space_reclaimer(self) -> None:
|
||||
self._space_reclaimer = SpaceReclaimer(
|
||||
self._space_monitor, self._out_dir,
|
||||
)
|
||||
self._settings_manager.apply_space_reclaimer_settings()
|
||||
self._space_reclaimer.enable()
|
||||
|
||||
def _setup_notifiers(self) -> None:
|
||||
self._email_notifier = EmailNotifier()
|
||||
self._serverchan_notifier = ServerchanNotifier()
|
||||
self._pushplus_notifier = PushplusNotifier()
|
||||
self._settings_manager.apply_email_notification_settings()
|
||||
self._settings_manager.apply_serverchan_notification_settings()
|
||||
self._settings_manager.apply_pushplus_notification_settings()
|
||||
|
||||
def _setup_webhooks(self) -> None:
|
||||
self._webhook_emitter = WebHookEmitter()
|
||||
self._settings_manager.apply_webhooks_settings()
|
||||
self._webhook_emitter.enable()
|
||||
|
||||
def _destroy(self) -> None:
|
||||
self._destroy_space_reclaimer()
|
||||
self._destroy_space_event_submitter()
|
||||
self._destroy_space_monitor()
|
||||
self._destroy_notifiers()
|
||||
self._destroy_webhooks()
|
||||
self._destroy_exception_handler()
|
||||
|
||||
def _destroy_space_monitor(self) -> None:
|
||||
self._space_monitor.disable()
|
||||
del self._space_monitor
|
||||
|
||||
def _destroy_space_event_submitter(self) -> None:
|
||||
del self._space_event_submitter
|
||||
|
||||
def _destroy_space_reclaimer(self) -> None:
|
||||
self._space_reclaimer.disable()
|
||||
del self._space_reclaimer
|
||||
|
||||
def _destroy_notifiers(self) -> None:
|
||||
self._email_notifier.disable()
|
||||
self._serverchan_notifier.disable()
|
||||
self._pushplus_notifier.disable()
|
||||
del self._email_notifier
|
||||
del self._serverchan_notifier
|
||||
del self._pushplus_notifier
|
||||
|
||||
def _destroy_webhooks(self) -> None:
|
||||
self._webhook_emitter.disable()
|
||||
del self._webhook_emitter
|
||||
|
||||
def _destroy_exception_handler(self) -> None:
|
||||
self._exception_handler.disable()
|
||||
del self._exception_handler
|
0
src/blrec/bili/__init__.py
Normal file
101
src/blrec/bili/api.py
Normal file
@ -0,0 +1,101 @@
|
||||
from typing import Any, Final
|
||||
|
||||
import aiohttp
|
||||
from tenacity import (
|
||||
retry,
|
||||
wait_exponential,
|
||||
stop_after_delay,
|
||||
)
|
||||
|
||||
from .typing import QualityNumber, JsonResponse, ResponseData
|
||||
from .exceptions import ApiRequestError
|
||||
|
||||
|
||||
__all__ = 'WebApi',
|
||||
|
||||
|
||||
class WebApi:
|
||||
BASE_API_URL: Final[str] = 'https://api.bilibili.com'
|
||||
BASE_LIVE_API_URL: Final[str] = 'https://api.live.bilibili.com'
|
||||
|
||||
GET_USER_INFO_URL: Final[str] = BASE_API_URL + '/x/space/acc/info'
|
||||
|
||||
ROOM_INIT_URL: Final[str] = BASE_LIVE_API_URL + '/room/v1/Room/room_init'
|
||||
GET_INFO_URL: Final[str] = BASE_LIVE_API_URL + '/room/v1/Room/get_info'
|
||||
GET_INFO_BY_ROOM_URL: Final[str] = BASE_LIVE_API_URL + \
|
||||
'/xlive/web-room/v1/index/getInfoByRoom'
|
||||
GET_ROOM_PLAY_INFO_URL: Final[str] = BASE_LIVE_API_URL + \
|
||||
'/xlive/web-room/v2/index/getRoomPlayInfo'
|
||||
GET_TIMESTAMP_URL: Final[str] = BASE_LIVE_API_URL + \
|
||||
'/av/v1/Time/getTimestamp?platform=pc'
|
||||
|
||||
def __init__(self, session: aiohttp.ClientSession):
|
||||
self._session = session
|
||||
self.timeout = 10
|
||||
|
||||
@staticmethod
|
||||
def _check_response(json_res: JsonResponse) -> None:
|
||||
if json_res['code'] != 0:
|
||||
raise ApiRequestError(
|
||||
json_res['code'],
|
||||
json_res.get('message') or json_res.get('msg') or '',
|
||||
)
|
||||
|
||||
@retry(
|
||||
reraise=True,
|
||||
stop=stop_after_delay(5),
|
||||
wait=wait_exponential(0.1),
|
||||
)
|
||||
async def _get(self, *args: Any, **kwds: Any) -> JsonResponse:
|
||||
async with self._session.get(
|
||||
*args,
|
||||
**kwds,
|
||||
timeout=self.timeout,
|
||||
) as res:
|
||||
json_res = await res.json()
|
||||
self._check_response(json_res)
|
||||
return json_res
|
||||
|
||||
async def room_init(self, room_id: int) -> ResponseData:
|
||||
r = await self._get(self.ROOM_INIT_URL, params={'id': room_id})
|
||||
return r['data']
|
||||
|
||||
async def get_room_play_info(
|
||||
self, room_id: int, qn: QualityNumber = 10000
|
||||
) -> ResponseData:
|
||||
params = {
|
||||
'room_id': room_id,
|
||||
'protocol': '0,1',
|
||||
'format': '0,2',
|
||||
'codec': '0,1',
|
||||
'qn': qn,
|
||||
'platform': 'web',
|
||||
'ptype': 16,
|
||||
}
|
||||
r = await self._get(self.GET_ROOM_PLAY_INFO_URL, params=params)
|
||||
return r['data']
|
||||
|
||||
async def get_info_by_room(self, room_id: int) -> ResponseData:
|
||||
params = {
|
||||
'room_id': room_id,
|
||||
}
|
||||
r = await self._get(self.GET_INFO_BY_ROOM_URL, params=params)
|
||||
return r['data']
|
||||
|
||||
async def get_info(self, room_id: int) -> ResponseData:
|
||||
params = {
|
||||
'room_id': room_id,
|
||||
}
|
||||
r = await self._get(self.GET_INFO_URL, params=params)
|
||||
return r['data']
|
||||
|
||||
async def get_timestamp(self) -> int:
|
||||
r = await self._get(self.GET_TIMESTAMP_URL)
|
||||
return r['data']['timestamp']
|
||||
|
||||
async def get_user_info(self, uid: int) -> ResponseData:
|
||||
params = {
|
||||
'mid': uid,
|
||||
}
|
||||
r = await self._get(self.GET_USER_INFO_URL, params=params)
|
||||
return r['data']
|
543
src/blrec/bili/danmaku_client.py
Normal file
@ -0,0 +1,543 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import struct
|
||||
import zlib
|
||||
import asyncio
|
||||
import logging
|
||||
from enum import IntEnum, Enum
|
||||
from contextlib import suppress
|
||||
from typing import Any, Dict, Final, Tuple, List, Union, cast, Optional
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientSession
|
||||
from tenacity import (
|
||||
retry,
|
||||
wait_exponential,
|
||||
retry_if_exception_type,
|
||||
)
|
||||
|
||||
from .typing import Danmaku
|
||||
from ..event.event_emitter import EventListener, EventEmitter
|
||||
from ..exception import exception_callback
|
||||
from ..utils.mixins import AsyncStoppableMixin
|
||||
from ..logging.room_id import aio_task_with_room_id
|
||||
|
||||
|
||||
__all__ = 'DanmakuClient', 'DanmakuListener', 'Danmaku', 'DanmakuCommand'
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DanmakuListener(EventListener):
|
||||
async def on_client_connected(self) -> None:
|
||||
...
|
||||
|
||||
async def on_client_disconnected(self) -> None:
|
||||
...
|
||||
|
||||
async def on_client_reconnected(self) -> None:
|
||||
...
|
||||
|
||||
async def on_danmaku_received(self, danmu: Danmaku) -> None:
|
||||
...
|
||||
|
||||
async def on_error_occurred(self, error: Exception) -> None:
|
||||
...
|
||||
|
||||
|
||||
class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
|
||||
_URL: Final[str] = 'wss://broadcastlv.chat.bilibili.com:443/sub'
|
||||
_HEARTBEAT_INTERVAL: Final[int] = 30
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: ClientSession,
|
||||
room_id: int,
|
||||
*,
|
||||
max_retries: int = 10,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.session = session
|
||||
self._room_id = room_id
|
||||
|
||||
self._retry_delay: int = 0
|
||||
self._MAX_RETRIES: Final[int] = max_retries
|
||||
|
||||
async def _do_start(self) -> None:
|
||||
await self._connect()
|
||||
await self._create_message_loop()
|
||||
logger.debug('Started danmaku client')
|
||||
|
||||
async def _do_stop(self) -> None:
|
||||
await self._terminate_message_loop()
|
||||
await self._disconnect()
|
||||
logger.debug('Stopped danmaku client')
|
||||
|
||||
async def reconnect(self) -> None:
|
||||
if self.stopped:
|
||||
return
|
||||
|
||||
logger.debug('Reconnecting...')
|
||||
await self._disconnect()
|
||||
await self._connect()
|
||||
await self._emit('client_reconnected')
|
||||
|
||||
@retry(
|
||||
wait=wait_exponential(multiplier=0.1, max=60),
|
||||
retry=retry_if_exception_type((
|
||||
asyncio.TimeoutError, aiohttp.ClientError,
|
||||
)),
|
||||
)
|
||||
async def _connect(self) -> None:
|
||||
logger.debug('Connecting to server...')
|
||||
await self._connect_websocket()
|
||||
await self._send_auth()
|
||||
reply = await self._recieve_auth_reply()
|
||||
self._handle_auth_reply(reply)
|
||||
logger.debug('Connected to server')
|
||||
await self._emit('client_connected')
|
||||
|
||||
async def _connect_websocket(self) -> None:
|
||||
self._ws = await self.session.ws_connect(self._URL, timeout=5)
|
||||
logger.debug('Established WebSocket connection')
|
||||
|
||||
async def _send_auth(self) -> None:
|
||||
auth_msg = json.dumps({
|
||||
'uid': 0,
|
||||
'roomid': self._room_id, # must not be the short id!
|
||||
# 'protover': WS.BODY_PROTOCOL_VERSION_NORMAL,
|
||||
'protover': WS.BODY_PROTOCOL_VERSION_DEFLATE,
|
||||
'platform': 'web',
|
||||
'clientver': '1.1.031',
|
||||
'type': 2
|
||||
})
|
||||
data = Frame.encode(WS.OP_USER_AUTHENTICATION, auth_msg)
|
||||
await self._ws.send_bytes(data)
|
||||
logger.debug('Sent user authentication')
|
||||
|
||||
async def _recieve_auth_reply(self) -> aiohttp.WSMessage:
|
||||
msg = await self._ws.receive(timeout=5)
|
||||
if msg.type != aiohttp.WSMsgType.BINARY:
|
||||
raise aiohttp.ClientError(msg)
|
||||
logger.debug('Recieved reply')
|
||||
return msg
|
||||
|
||||
def _handle_auth_reply(self, reply: aiohttp.WSMessage) -> None:
|
||||
op, msg = Frame.decode(reply.data)
|
||||
assert op == WS.OP_CONNECT_SUCCESS
|
||||
msg = cast(str, msg)
|
||||
code = cast(int, json.loads(msg)['code'])
|
||||
|
||||
if code == WS.AUTH_OK:
|
||||
logger.debug('Auth OK')
|
||||
self._create_heartbeat_task()
|
||||
elif code == WS.AUTH_TOKEN_ERROR:
|
||||
logger.debug('Auth Token Error')
|
||||
raise ValueError(f'Auth Token Error: {code}')
|
||||
else:
|
||||
raise ValueError(f'Unexpected code: {code}')
|
||||
|
||||
async def _disconnect(self) -> None:
|
||||
await self._cancel_heartbeat_task()
|
||||
await self._close_websocket()
|
||||
logger.debug('Disconnected from server')
|
||||
await self._emit('client_disconnected')
|
||||
|
||||
async def _close_websocket(self) -> None:
|
||||
with suppress(BaseException):
|
||||
await self._ws.close()
|
||||
|
||||
def _create_heartbeat_task(self) -> None:
|
||||
self._heartbeat_task = asyncio.create_task(self._send_heartbeat())
|
||||
self._heartbeat_task.add_done_callback(exception_callback)
|
||||
|
||||
async def _cancel_heartbeat_task(self) -> None:
|
||||
self._heartbeat_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._heartbeat_task
|
||||
|
||||
@aio_task_with_room_id
|
||||
async def _send_heartbeat(self) -> None:
|
||||
data = Frame.encode(WS.OP_HEARTBEAT, '')
|
||||
while True:
|
||||
await self._ws.send_bytes(data)
|
||||
await asyncio.sleep(self._HEARTBEAT_INTERVAL)
|
||||
|
||||
async def _create_message_loop(self) -> None:
|
||||
self._message_loop_task = asyncio.create_task(self._message_loop())
|
||||
self._message_loop_task.add_done_callback(exception_callback)
|
||||
logger.debug('Created message loop')
|
||||
|
||||
async def _terminate_message_loop(self) -> None:
|
||||
self._message_loop_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._message_loop_task
|
||||
logger.debug('Terminated message loop')
|
||||
|
||||
@aio_task_with_room_id
|
||||
async def _message_loop(self) -> None:
|
||||
while True:
|
||||
for msg in await self._receive():
|
||||
await self._dispatch_message(msg)
|
||||
|
||||
async def _dispatch_message(self, msg: Dict[str, Any]) -> None:
|
||||
await self._emit('danmaku_received', msg)
|
||||
|
||||
@retry(retry=retry_if_exception_type((asyncio.TimeoutError,)))
|
||||
async def _receive(self) -> List[Dict[str, Any]]:
|
||||
self._retry_count = 0
|
||||
self._retry_delay = 0
|
||||
|
||||
while True:
|
||||
wsmsg = await self._ws.receive(timeout=self._HEARTBEAT_INTERVAL)
|
||||
|
||||
if wsmsg.type == aiohttp.WSMsgType.BINARY:
|
||||
if (result := await self._handle_data(wsmsg.data)):
|
||||
return result
|
||||
elif wsmsg.type == aiohttp.WSMsgType.ERROR:
|
||||
await self._handle_error(cast(Exception, wsmsg.data))
|
||||
elif wsmsg.type == aiohttp.WSMsgType.CLOSED:
|
||||
msg = 'WebSocket Closed'
|
||||
exc = aiohttp.WebSocketError(self._ws.close_code or 1006, msg)
|
||||
await self._handle_error(exc)
|
||||
else:
|
||||
raise ValueError(wsmsg)
|
||||
|
||||
@staticmethod
|
||||
async def _handle_data(data: bytes) -> Optional[List[Dict[str, Any]]]:
|
||||
loop = asyncio.get_running_loop()
|
||||
op, msg = await loop.run_in_executor(None, Frame.decode, data)
|
||||
|
||||
if op == WS.OP_MESSAGE:
|
||||
msg = cast(List[str], msg)
|
||||
return [json.loads(m) for m in msg]
|
||||
elif op == WS.OP_HEARTBEAT_REPLY:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
async def _handle_error(self, exc: Exception) -> None:
|
||||
logger.debug(f'Failed to receive message due to: {repr(exc)}')
|
||||
await self._emit('error_occurred', exc)
|
||||
await self._retry()
|
||||
|
||||
async def _retry(self) -> None:
|
||||
if self._retry_count < self._MAX_RETRIES:
|
||||
if self._retry_delay > 0:
|
||||
logger.debug('Retry after {} second{}'.format(
|
||||
self._retry_delay,
|
||||
's' if self._retry_delay > 1 else '',
|
||||
))
|
||||
await asyncio.sleep(self._retry_delay)
|
||||
await self.reconnect()
|
||||
self._retry_count += 1
|
||||
self._retry_delay += 1
|
||||
else:
|
||||
raise aiohttp.WebSocketError(1006, 'Over the maximum of retries')
|
||||
|
||||
|
||||
class Frame:
|
||||
HEADER_FORMAT = '>IHHII'
|
||||
|
||||
@staticmethod
|
||||
def encode(op: int, msg: str) -> bytes:
|
||||
body = msg.encode()
|
||||
header_length = WS.PACKAGE_HEADER_TOTAL_LENGTH
|
||||
packet_length = header_length + len(body)
|
||||
ver = WS.HEADER_DEFAULT_VERSION
|
||||
seq = WS.HEADER_DEFAULT_SEQUENCE
|
||||
|
||||
header = struct.pack(
|
||||
Frame.HEADER_FORMAT,
|
||||
packet_length,
|
||||
header_length,
|
||||
ver, # protocal version
|
||||
op, # operation
|
||||
seq, # sequence id
|
||||
)
|
||||
|
||||
return header + body
|
||||
|
||||
@staticmethod
|
||||
def decode(data: bytes) -> Tuple[int, Union[int, str, List[str]]]:
|
||||
plen, hlen, ver, op, _ = struct.unpack_from(
|
||||
Frame.HEADER_FORMAT, data, 0
|
||||
)
|
||||
body = data[hlen:]
|
||||
|
||||
if op == WS.OP_MESSAGE:
|
||||
if ver == WS.BODY_PROTOCOL_VERSION_DEFLATE:
|
||||
data = zlib.decompress(body)
|
||||
|
||||
msg_list = []
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
plen, hlen, ver, op, _ = struct.unpack_from(
|
||||
Frame.HEADER_FORMAT, data, offset
|
||||
)
|
||||
body = data[hlen + offset:plen + offset]
|
||||
msg = body.decode('utf8')
|
||||
msg_list.append(msg)
|
||||
offset += plen
|
||||
|
||||
return op, msg_list
|
||||
elif op == WS.OP_HEARTBEAT_REPLY:
|
||||
online_count = cast(int, struct.unpack('>I', body)[0])
|
||||
return op, online_count
|
||||
elif op == WS.OP_CONNECT_SUCCESS:
|
||||
auth_result = body.decode()
|
||||
return op, auth_result
|
||||
else:
|
||||
raise ValueError(f'Unexpected Operation: {op}')
|
||||
|
||||
|
||||
class WS(IntEnum):
|
||||
AUTH_OK = 0
|
||||
AUTH_TOKEN_ERROR = -101
|
||||
BODY_PROTOCOL_VERSION_DEFLATE = 2
|
||||
BODY_PROTOCOL_VERSION_NORMAL = 0
|
||||
HEADER_DEFAULT_OPERATION = 1
|
||||
HEADER_DEFAULT_SEQUENCE = 1
|
||||
HEADER_DEFAULT_VERSION = 1
|
||||
HEADER_OFFSET = 4
|
||||
OP_CONNECT_SUCCESS = 8
|
||||
OP_HEARTBEAT = 2
|
||||
OP_HEARTBEAT_REPLY = 3
|
||||
OP_MESSAGE = 5
|
||||
OP_USER_AUTHENTICATION = 7
|
||||
OPERATION_OFFSET = 8
|
||||
PACKAGE_HEADER_TOTAL_LENGTH = 16
|
||||
PACKAGE_OFFSET = 0
|
||||
SEQUENCE_OFFSET = 12
|
||||
VERSION_OFFSET = 6
|
||||
|
||||
|
||||
class DanmakuCommand(Enum):
|
||||
ACTIVITY_MATCH_GIFT = 'ACTIVITY_MATCH_GIFT'
|
||||
ANCHOR_LOT_AWARD = 'ANCHOR_LOT_AWARD'
|
||||
ANCHOR_LOT_CHECKSTATUS = 'ANCHOR_LOT_CHECKSTATUS'
|
||||
ANCHOR_LOT_END = 'ANCHOR_LOT_END'
|
||||
ANCHOR_LOT_START = 'ANCHOR_LOT_START'
|
||||
ANIMATION = 'ANIMATION'
|
||||
BOX_ACTIVITY_START = 'BOX_ACTIVITY_START'
|
||||
CHANGE_ROOM_INFO = 'CHANGE_ROOM_INFO'
|
||||
CHASE_FRAME_SWITCH = 'CHASE_FRAME_SWITCH'
|
||||
COMBO_SEND = 'COMBO_SEND'
|
||||
CUT_OFF = 'CUT_OFF'
|
||||
DANMU_GIFT_LOTTERY_AWARD = 'DANMU_GIFT_LOTTERY_AWARD'
|
||||
DANMU_GIFT_LOTTERY_END = 'DANMU_GIFT_LOTTERY_END'
|
||||
DANMU_GIFT_LOTTERY_START = 'DANMU_GIFT_LOTTERY_START'
|
||||
DANMU_MSG = 'DANMU_MSG'
|
||||
ENTRY_EFFECT = 'ENTRY_EFFECT'
|
||||
GUARD_ACHIEVEMENT_ROOM = 'GUARD_ACHIEVEMENT_ROOM'
|
||||
GUARD_LOTTERY_START = 'GUARD_LOTTERY_START'
|
||||
HOUR_RANK_AWARDS = 'HOUR_RANK_AWARDS'
|
||||
LITTLE_TIPS = 'LITTLE_TIPS'
|
||||
LIVE = 'LIVE'
|
||||
LOL_ACTIVITY = 'LOL_ACTIVITY'
|
||||
LUCK_GIFT_AWARD_USER = 'LUCK_GIFT_AWARD_USER'
|
||||
MATCH_TEAM_GIFT_RANK = 'MATCH_TEAM_GIFT_RANK'
|
||||
MESSAGEBOX_USER_GAIN_MEDAL = 'MESSAGEBOX_USER_GAIN_MEDAL'
|
||||
NOTICE_MSG = 'NOTICE_MSG'
|
||||
PK_AGAIN = 'PK_AGAIN'
|
||||
PK_BATTLE_CRIT = 'PK_BATTLE_CRIT'
|
||||
PK_BATTLE_END = 'PK_BATTLE_END'
|
||||
PK_BATTLE_GIFT = 'PK_BATTLE_GIFT'
|
||||
PK_BATTLE_PRE = 'PK_BATTLE_PRE'
|
||||
PK_BATTLE_PRO_TYPE = 'PK_BATTLE_PRO_TYPE'
|
||||
PK_BATTLE_PROCESS = 'PK_BATTLE_PROCESS'
|
||||
PK_BATTLE_RANK_CHANGE = 'PK_BATTLE_RANK_CHANGE'
|
||||
PK_BATTLE_SETTLE_USER = 'PK_BATTLE_SETTLE_USER'
|
||||
PK_BATTLE_SPECIAL_GIFT = 'PK_BATTLE_SPECIAL_GIFT'
|
||||
PK_BATTLE_START = 'PK_BATTLE_START'
|
||||
PK_BATTLE_VOTES_ADD = 'PK_BATTLE_VOTES_ADD'
|
||||
PK_END = 'PK_END'
|
||||
PK_LOTTERY_START = 'PK_LOTTERY_START'
|
||||
PK_MATCH = 'PK_MATCH'
|
||||
PK_MIC_END = 'PK_MIC_END'
|
||||
PK_PRE = 'PK_PRE'
|
||||
PK_PROCESS = 'PK_PROCESS'
|
||||
PK_SETTLE = 'PK_SETTLE'
|
||||
PK_START = 'PK_START'
|
||||
PREPARING = 'PREPARING'
|
||||
RAFFLE_END = 'RAFFLE_END'
|
||||
RAFFLE_START = 'RAFFLE_START'
|
||||
ROOM_BLOCK_INTO = 'ROOM_BLOCK_INTO'
|
||||
ROOM_BLOCK_MSG = 'ROOM_BLOCK_MSG'
|
||||
ROOM_BOX_USER = 'ROOM_BOX_USER'
|
||||
ROOM_CHANGE = 'ROOM_CHANGE'
|
||||
ROOM_KICKOUT = 'ROOM_KICKOUT'
|
||||
ROOM_LIMIT = 'ROOM_LIMIT'
|
||||
ROOM_LOCK = 'ROOM_LOCK'
|
||||
ROOM_RANK = 'ROOM_RANK'
|
||||
ROOM_REAL_TIME_MESSAGE_UPDATE = 'ROOM_REAL_TIME_MESSAGE_UPDATE'
|
||||
ROOM_REFRESH = 'ROOM_REFRESH'
|
||||
ROOM_SILENT_OFF = 'ROOM_SILENT_OFF'
|
||||
ROOM_SILENT_ON = 'ROOM_SILENT_ON'
|
||||
ROOM_SKIN_MSG = 'ROOM_SKIN_MSG'
|
||||
SCORE_CARD = 'SCORE_CARD'
|
||||
SEND_GIFT = 'SEND_GIFT'
|
||||
SEND_TOP = 'SEND_TOP'
|
||||
SPECIAL_GIFT = 'SPECIAL_GIFT'
|
||||
SUPER_CHAT_ENTRANCE = 'SUPER_CHAT_ENTRANCE'
|
||||
SUPER_CHAT_MESSAGE = 'SUPER_CHAT_MESSAGE'
|
||||
SUPER_CHAT_MESSAGE_DELETE = 'SUPER_CHAT_MESSAGE_DELETE'
|
||||
TV_END = 'TV_END'
|
||||
TV_START = 'TV_START'
|
||||
USER_TOAST_MSG = 'USER_TOAST_MSG'
|
||||
VOICE_JOIN_STATUS = 'VOICE_JOIN_STATUS'
|
||||
WARNING = 'WARNING'
|
||||
WATCH_LPL_EXPIRED = 'WATCH_LPL_EXPIRED'
|
||||
WEEK_STAR_CLOCK = 'WEEK_STAR_CLOCK'
|
||||
WELCOME = 'WELCOME'
|
||||
WELCOME_GUARD = 'WELCOME_GUARD'
|
||||
WIN_ACTIVITY = 'WIN_ACTIVITY'
|
||||
WIN_ACTIVITY_USER = 'WIN_ACTIVITY_USER'
|
||||
WISH_BOTTLE = 'WISH_BOTTLE'
|
||||
# ...
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
def add_task_name(record: logging.LogRecord) -> bool:
|
||||
try:
|
||||
task = asyncio.current_task()
|
||||
assert task is not None
|
||||
except Exception:
|
||||
name = ''
|
||||
else:
|
||||
name = task.get_name()
|
||||
|
||||
if '::' in name:
|
||||
record.roomid = '[' + name.split('::')[-1] + '] ' # type: ignore
|
||||
else:
|
||||
record.roomid = '' # type: ignore
|
||||
|
||||
return True
|
||||
|
||||
def configure_logger() -> None:
|
||||
# config root logger
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# config formatter
|
||||
fmt = '[%(asctime)s] [%(levelname)s] %(roomid)s%(message)s'
|
||||
formatter = logging.Formatter(fmt)
|
||||
|
||||
# logging to console
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.addFilter(add_task_name)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
async def get_room_list(
|
||||
session: ClientSession, count: int = 99
|
||||
) -> List[Dict[str, Any]]:
|
||||
url = 'https://api.live.bilibili.com/room/v3/area/getRoomList'
|
||||
params = {
|
||||
'platform': 'web',
|
||||
'parent_area_id': 1,
|
||||
'cate_id': 0,
|
||||
'area_id': 0,
|
||||
'sort_type': 'online',
|
||||
'page': 1,
|
||||
'page_size': count,
|
||||
'tag_version': 1,
|
||||
}
|
||||
async with session.get(url, params=params) as response:
|
||||
j = await response.json()
|
||||
return j['data']['list']
|
||||
|
||||
class DanmakuPrinter(DanmakuListener):
|
||||
def __init__(
|
||||
self, danmaku_client: DanmakuClient, room_id: int
|
||||
) -> None:
|
||||
self._danmaku_client = danmaku_client
|
||||
self._room_id = room_id
|
||||
|
||||
async def enable(self) -> None:
|
||||
self._danmaku_client.add_listener(self)
|
||||
|
||||
async def disable(self) -> None:
|
||||
self._danmaku_client.remove_listener(self)
|
||||
|
||||
async def on_danmaku_received(self, danmu: Danmaku) -> None:
|
||||
json_string = json.dumps(danmu, ensure_ascii=False)
|
||||
logger.info(f'{json_string}')
|
||||
|
||||
class DanmakuDumper(DanmakuListener):
|
||||
def __init__(
|
||||
self, danmaku_client: DanmakuClient, room_id: int
|
||||
) -> None:
|
||||
self._danmaku_client = danmaku_client
|
||||
from datetime import datetime
|
||||
data_time_string = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||||
self._filename = f'blive_cmd_{room_id}_{data_time_string}.jsonl'
|
||||
|
||||
async def start(self) -> None:
|
||||
import aiofiles
|
||||
self._file = await aiofiles.open(
|
||||
self._filename, mode='wt', encoding='utf8'
|
||||
)
|
||||
logger.debug(f'Opened file: {self._filename}')
|
||||
self._danmaku_client.add_listener(self)
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._danmaku_client.remove_listener(self)
|
||||
await self._file.close()
|
||||
logger.debug(f'Closed file: {self._filename}')
|
||||
|
||||
async def on_danmaku_received(self, danmu: Danmaku) -> None:
|
||||
json_string = json.dumps(danmu, ensure_ascii=False)
|
||||
await self._file.write(json_string + '\n')
|
||||
|
||||
async def test_room(session: ClientSession, room_id: int) -> None:
|
||||
client = DanmakuClient(session, room_id)
|
||||
printer = DanmakuPrinter(client, room_id)
|
||||
dumper = DanmakuDumper(client, room_id)
|
||||
|
||||
await printer.enable()
|
||||
await dumper.start()
|
||||
await client.start()
|
||||
|
||||
keyboard_interrupt_event = asyncio.Event()
|
||||
try:
|
||||
await keyboard_interrupt_event.wait()
|
||||
finally:
|
||||
await client.stop()
|
||||
await dumper.stop()
|
||||
await printer.disable()
|
||||
|
||||
async def test() -> None:
|
||||
import sys
|
||||
try:
|
||||
# the room id must not be the short id!
|
||||
room_ids = list(map(int, sys.argv[1:]))
|
||||
except ValueError:
|
||||
print('Usage: room_id, ...')
|
||||
sys.exit(0)
|
||||
|
||||
configure_logger()
|
||||
|
||||
async with ClientSession() as session:
|
||||
tasks = []
|
||||
|
||||
if not room_ids:
|
||||
room_list = await get_room_list(session)
|
||||
room_ids = [room['roomid'] for room in room_list]
|
||||
|
||||
logger.debug(f'room count: {len(room_ids)}')
|
||||
|
||||
for room_id in room_ids:
|
||||
task = asyncio.create_task(
|
||||
test_room(session, room_id), name=f'test_room::{room_id}',
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
await asyncio.wait(tasks)
|
||||
|
||||
try:
|
||||
asyncio.run(test())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
24
src/blrec/bili/exceptions.py
Normal file
@ -0,0 +1,24 @@
|
||||
|
||||
import attr
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, frozen=True, slots=True)
|
||||
class ApiRequestError(Exception):
|
||||
code: int
|
||||
message: str
|
||||
|
||||
|
||||
class LiveRoomHidden(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class LiveRoomLocked(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class LiveRoomEncrypted(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NoStreamUrlAvailable(Exception):
|
||||
pass
|
29
src/blrec/bili/helpers.py
Normal file
@ -0,0 +1,29 @@
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .api import WebApi
|
||||
from .typing import ResponseData
|
||||
from .exceptions import ApiRequestError
|
||||
from ..exception import NotFoundError
|
||||
|
||||
|
||||
__all__ = 'room_init', 'ensure_room_id'
|
||||
|
||||
|
||||
async def room_init(room_id: int) -> ResponseData:
|
||||
async with aiohttp.ClientSession(raise_for_status=True) as session:
|
||||
api = WebApi(session)
|
||||
return await api.room_init(room_id)
|
||||
|
||||
|
||||
async def ensure_room_id(room_id: int) -> int:
|
||||
"""Ensure room id is valid and is the real room id"""
|
||||
try:
|
||||
result = await room_init(room_id)
|
||||
except ApiRequestError as e:
|
||||
if e.code == 60004:
|
||||
raise NotFoundError(f'the room {room_id} not existed')
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
return result['room_id']
|
221
src/blrec/bili/live.py
Normal file
@ -0,0 +1,221 @@
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import json
|
||||
from typing import Dict, List, Optional, cast
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .api import WebApi
|
||||
from .models import LiveStatus, RoomInfo, UserInfo
|
||||
from .typing import QualityNumber, StreamFormat, ResponseData
|
||||
from .exceptions import (
|
||||
LiveRoomHidden, LiveRoomLocked, LiveRoomEncrypted, NoStreamUrlAvailable
|
||||
)
|
||||
|
||||
|
||||
__all__ = 'Live',
|
||||
|
||||
|
||||
_INFO_PATTERN = re.compile(
|
||||
rb'<script>\s*window\.__NEPTUNE_IS_MY_WAIFU__\s*=\s*(\{.*?\})\s*</script>'
|
||||
)
|
||||
_LIVE_STATUS_PATTERN = re.compile(rb'"live_status"\s*:\s*(\d)')
|
||||
|
||||
|
||||
class Live:
|
||||
def __init__(
|
||||
self, room_id: int, user_agent: str = '', cookie: str = ''
|
||||
) -> None:
|
||||
self._room_id = room_id
|
||||
self._user_agent = user_agent
|
||||
self._cookie = cookie
|
||||
self._html_page_url = f'https://live.bilibili.com/{room_id}'
|
||||
|
||||
@property
|
||||
def user_agent(self) -> str:
|
||||
return self._user_agent
|
||||
|
||||
@user_agent.setter
|
||||
def user_agent(self, value: str) -> None:
|
||||
self._user_agent = value
|
||||
|
||||
@property
|
||||
def cookie(self) -> str:
|
||||
return self._cookie
|
||||
|
||||
@cookie.setter
|
||||
def cookie(self, value: str) -> None:
|
||||
self._cookie = value
|
||||
|
||||
@property
|
||||
def headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
'Referer': 'https://live.bilibili.com/',
|
||||
'Connection': 'Keep-Alive',
|
||||
'User-Agent': self._user_agent,
|
||||
'cookie': self._cookie,
|
||||
}
|
||||
|
||||
@property
|
||||
def session(self) -> aiohttp.ClientSession:
|
||||
return self._session
|
||||
|
||||
@property
|
||||
def room_id(self) -> int:
|
||||
return self._room_id
|
||||
|
||||
@property
|
||||
def room_info(self) -> RoomInfo:
|
||||
return self._room_info
|
||||
|
||||
@property
|
||||
def user_info(self) -> UserInfo:
|
||||
return self._user_info
|
||||
|
||||
async def init(self) -> None:
|
||||
self._session = aiohttp.ClientSession(
|
||||
headers=self.headers,
|
||||
raise_for_status=True,
|
||||
trust_env=True,
|
||||
)
|
||||
self._api = WebApi(self._session)
|
||||
|
||||
self._room_info = await self.get_room_info()
|
||||
self._user_info = await self.get_user_info(self._room_info.uid)
|
||||
|
||||
async def deinit(self) -> None:
|
||||
await self._session.close()
|
||||
|
||||
async def get_live_status(self) -> LiveStatus:
|
||||
try:
|
||||
# frequent requests will be intercepted by the server's firewall!
|
||||
live_status = await self._get_live_status_via_api()
|
||||
except Exception:
|
||||
# more cpu consumption
|
||||
live_status = await self._get_live_status_via_html_page()
|
||||
|
||||
return LiveStatus(live_status)
|
||||
|
||||
def is_living(self) -> bool:
|
||||
return self._room_info.live_status == LiveStatus.LIVE
|
||||
|
||||
async def update_info(self) -> None:
|
||||
await asyncio.wait([self.update_user_info(), self.update_room_info()])
|
||||
|
||||
async def update_user_info(self) -> None:
|
||||
self._user_info = await self.get_user_info(self._room_info.uid)
|
||||
|
||||
async def update_room_info(self) -> None:
|
||||
self._room_info = await self.get_room_info()
|
||||
|
||||
async def get_room_info(self) -> RoomInfo:
|
||||
try:
|
||||
# frequent requests will be intercepted by the server's firewall!
|
||||
room_info_data = await self._get_room_info_via_api()
|
||||
except Exception:
|
||||
# more cpu consumption
|
||||
room_info_data = await self._get_room_info_via_html_page()
|
||||
|
||||
return RoomInfo.from_data(room_info_data)
|
||||
|
||||
async def get_user_info(self, uid: int) -> UserInfo:
|
||||
user_info_data = await self._api.get_user_info(uid)
|
||||
return UserInfo.from_data(user_info_data)
|
||||
|
||||
async def get_server_timestamp(self) -> int:
|
||||
# the timestamp on the server at the moment in seconds
|
||||
return await self._api.get_timestamp()
|
||||
|
||||
async def get_live_stream_url(
|
||||
self, qn: QualityNumber = 10000, format: StreamFormat = 'flv'
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
data = await self._api.get_room_play_info(self._room_id, qn)
|
||||
except Exception:
|
||||
# fallback to the html page global info
|
||||
data = await self._get_room_play_info_via_html_page()
|
||||
self._check_room_play_info(data)
|
||||
stream = data['playurl_info']['playurl']['stream']
|
||||
if stream[0]['format'][0]['codec'][0]['current_qn'] != qn:
|
||||
raise
|
||||
else:
|
||||
self._check_room_play_info(data)
|
||||
|
||||
streams = list(filter(
|
||||
lambda s: s['format'][0]['format_name'] == format,
|
||||
data['playurl_info']['playurl']['stream']
|
||||
))
|
||||
codec = streams[0]['format'][0]['codec'][0]
|
||||
accept_qn = cast(List[QualityNumber], codec['accept_qn'])
|
||||
|
||||
if qn not in accept_qn:
|
||||
return None
|
||||
|
||||
assert codec['current_qn'] == qn
|
||||
url_info = codec['url_info'][0]
|
||||
|
||||
return url_info['host'] + codec['base_url'] + url_info['extra']
|
||||
|
||||
def _check_room_play_info(self, data: ResponseData) -> None:
|
||||
if data['is_hidden']:
|
||||
raise LiveRoomHidden()
|
||||
if data['is_locked']:
|
||||
raise LiveRoomLocked()
|
||||
if data['encrypted'] and not data['pwd_verified']:
|
||||
raise LiveRoomEncrypted()
|
||||
try:
|
||||
data['playurl_info']['playurl']['stream'][0]
|
||||
except Exception:
|
||||
raise NoStreamUrlAvailable()
|
||||
|
||||
async def _get_live_status_via_api(self) -> int:
|
||||
room_info_data = await self._get_room_info_via_api()
|
||||
return int(room_info_data['live_status'])
|
||||
|
||||
async def _get_room_info_via_api(self) -> ResponseData:
|
||||
try:
|
||||
room_info_data = await self._api.get_info(self._room_id)
|
||||
except Exception:
|
||||
info_data = await self._api.get_info_by_room(self._room_id)
|
||||
room_info_data = info_data['room_info']
|
||||
|
||||
return room_info_data
|
||||
|
||||
async def _get_live_status_via_html_page(self) -> int:
|
||||
async with self._session.get(self._html_page_url) as response:
|
||||
data = await response.read()
|
||||
|
||||
m = _LIVE_STATUS_PATTERN.search(data)
|
||||
assert m is not None, data
|
||||
|
||||
return int(m.group(1))
|
||||
|
||||
async def _get_room_info_via_html_page(self) -> ResponseData:
|
||||
info_res = await self._get_room_info_res_via_html_page()
|
||||
return info_res['room_info']
|
||||
|
||||
async def _get_room_play_info_via_html_page(self) -> ResponseData:
|
||||
return await self._get_room_init_res_via_html_page()
|
||||
|
||||
async def _get_room_info_res_via_html_page(self) -> ResponseData:
|
||||
info = await self._get_info_via_html_page()
|
||||
if info['roomInfoRes']['code'] != 0:
|
||||
raise ValueError(f"Invaild roomInfoRes: {info['roomInfoRes']}")
|
||||
return info['roomInfoRes']['data']
|
||||
|
||||
async def _get_room_init_res_via_html_page(self) -> ResponseData:
|
||||
info = await self._get_info_via_html_page()
|
||||
if info['roomInitRes']['code'] != 0:
|
||||
raise ValueError(f"Invaild roomInitRes: {info['roomInitRes']}")
|
||||
return info['roomInitRes']['data']
|
||||
|
||||
async def _get_info_via_html_page(self) -> ResponseData:
|
||||
async with self._session.get(self._html_page_url) as response:
|
||||
data = await response.read()
|
||||
|
||||
m = _INFO_PATTERN.search(data)
|
||||
assert m is not None, data
|
||||
|
||||
string = m.group(1).decode(encoding='utf8')
|
||||
return json.loads(string)
|
134
src/blrec/bili/live_monitor.py
Normal file
@ -0,0 +1,134 @@
|
||||
import logging
|
||||
|
||||
|
||||
from .danmaku_client import DanmakuClient, DanmakuListener, DanmakuCommand
|
||||
from .live import Live
|
||||
from .typing import Danmaku
|
||||
from .models import LiveStatus, RoomInfo
|
||||
from ..event.event_emitter import EventListener, EventEmitter
|
||||
from ..utils.mixins import SwitchableMixin
|
||||
|
||||
|
||||
__all__ = 'LiveMonitor', 'LiveEventListener'
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LiveEventListener(EventListener):
|
||||
async def on_live_status_changed(
|
||||
self, current_status: LiveStatus, previous_status: LiveStatus
|
||||
) -> None:
|
||||
...
|
||||
|
||||
async def on_live_began(self, live: Live) -> None:
|
||||
...
|
||||
|
||||
async def on_live_ended(self, live: Live) -> None:
|
||||
...
|
||||
|
||||
async def on_live_stream_available(self, live: Live) -> None:
|
||||
...
|
||||
|
||||
async def on_live_stream_reset(self, live: Live) -> None:
|
||||
...
|
||||
|
||||
async def on_room_changed(self, room_info: RoomInfo) -> None:
|
||||
...
|
||||
|
||||
|
||||
class LiveMonitor(
|
||||
EventEmitter[LiveEventListener], DanmakuListener, SwitchableMixin
|
||||
):
|
||||
def __init__(self, danmaku_client: DanmakuClient, live: Live) -> None:
|
||||
super().__init__()
|
||||
self._danmaku_client = danmaku_client
|
||||
self._live = live
|
||||
|
||||
def _init_status(self) -> None:
|
||||
self._previous_status = self._live.room_info.live_status
|
||||
if self._live.is_living():
|
||||
self._status_count = 2
|
||||
else:
|
||||
self._status_count = 0
|
||||
|
||||
def _do_enable(self) -> None:
|
||||
self._init_status()
|
||||
self._danmaku_client.add_listener(self)
|
||||
logger.debug('Enabled live monitor')
|
||||
|
||||
def _do_disable(self) -> None:
|
||||
self._danmaku_client.remove_listener(self)
|
||||
logger.debug('Disabled live monitor')
|
||||
|
||||
async def on_client_reconnected(self) -> None:
|
||||
# check the live status after the client reconnected and simulate
|
||||
# events if necessary.
|
||||
# make sure the recorder works well continuously after interruptions
|
||||
# such as an operating system hibernation.
|
||||
logger.warning('The Danmaku Client Reconnected')
|
||||
|
||||
await self._live.update_info()
|
||||
current_status = self._live.room_info.live_status
|
||||
|
||||
if current_status == self._previous_status:
|
||||
return
|
||||
|
||||
if current_status == LiveStatus.LIVE:
|
||||
logger.debug('Simulating live began event')
|
||||
await self._handle_status_change(current_status)
|
||||
logger.debug('Simulating live stream available event')
|
||||
await self._handle_status_change(current_status)
|
||||
else:
|
||||
logger.debug('Simulating live ended event')
|
||||
await self._handle_status_change(current_status)
|
||||
|
||||
async def on_danmaku_received(self, danmu: Danmaku) -> None:
|
||||
danmu_cmd = danmu['cmd']
|
||||
|
||||
if danmu_cmd == DanmakuCommand.LIVE.value:
|
||||
await self._handle_status_change(LiveStatus.LIVE)
|
||||
elif danmu_cmd == DanmakuCommand.PREPARING.value:
|
||||
if danmu.get('round', None) == 1:
|
||||
await self._handle_status_change(LiveStatus.ROUND)
|
||||
else:
|
||||
await self._handle_status_change(LiveStatus.PREPARING)
|
||||
elif danmu_cmd == DanmakuCommand.ROOM_CHANGE.value:
|
||||
await self._live.update_room_info()
|
||||
await self._emit('room_changed', self._live.room_info)
|
||||
|
||||
async def _handle_status_change(self, current_status: LiveStatus) -> None:
|
||||
logger.debug('Live status changed from {} to {}'.format(
|
||||
self._previous_status.name, current_status.name
|
||||
))
|
||||
|
||||
await self._live.update_info()
|
||||
assert self._live.room_info.live_status == current_status
|
||||
|
||||
await self._emit(
|
||||
'live_status_changed', current_status, self._previous_status
|
||||
)
|
||||
|
||||
if current_status != LiveStatus.LIVE:
|
||||
self._status_count = 0
|
||||
await self._emit('live_ended', self._live)
|
||||
else:
|
||||
self._status_count += 1
|
||||
|
||||
if self._status_count == 1:
|
||||
assert self._previous_status != LiveStatus.LIVE
|
||||
await self._emit('live_began', self._live)
|
||||
elif self._status_count == 2:
|
||||
assert self._previous_status == LiveStatus.LIVE
|
||||
await self._emit('live_stream_available', self._live)
|
||||
elif self._status_count > 2:
|
||||
assert self._previous_status == LiveStatus.LIVE
|
||||
await self._emit('live_stream_reset', self._live)
|
||||
else:
|
||||
pass
|
||||
|
||||
logger.debug('Number of sequential LIVE status: {}'.format(
|
||||
self._status_count
|
||||
))
|
||||
|
||||
self._previous_status = current_status
|
85
src/blrec/bili/models.py
Normal file
@ -0,0 +1,85 @@
|
||||
from enum import IntEnum
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
|
||||
import attr
|
||||
|
||||
from .typing import ResponseData
|
||||
|
||||
|
||||
__all__ = 'LiveStatus', 'RoomInfo', 'UserInfo'
|
||||
|
||||
|
||||
class LiveStatus(IntEnum):
|
||||
PREPARING = 0
|
||||
LIVE = 1
|
||||
ROUND = 2
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, frozen=True, slots=True)
|
||||
class RoomInfo:
|
||||
uid: int
|
||||
room_id: int
|
||||
short_room_id: int
|
||||
area_id: int
|
||||
area_name: str
|
||||
parent_area_id: int
|
||||
parent_area_name: str
|
||||
live_status: LiveStatus
|
||||
live_start_time: int # a integer in seconds
|
||||
online: int
|
||||
title: str
|
||||
cover: str
|
||||
tags: str
|
||||
description: str
|
||||
|
||||
@staticmethod
|
||||
def from_data(data: ResponseData) -> 'RoomInfo':
|
||||
if (timestamp := data.get('live_start_time', None)) is not None:
|
||||
live_start_time = cast(int, timestamp)
|
||||
elif (time_string := data.get('live_time', None)) is not None:
|
||||
if time_string == '0000-00-00 00:00:00':
|
||||
live_start_time = 0
|
||||
else:
|
||||
dt = datetime.fromisoformat(time_string)
|
||||
live_start_time = int(dt.timestamp())
|
||||
else:
|
||||
raise ValueError(f'Failed to init live_start_time: {data}')
|
||||
|
||||
return RoomInfo(
|
||||
uid=data['uid'],
|
||||
room_id=int(data['room_id']),
|
||||
short_room_id=int(data['short_id']),
|
||||
area_id=data['area_id'],
|
||||
area_name=data['area_name'],
|
||||
parent_area_id=data['parent_area_id'],
|
||||
parent_area_name=data['parent_area_name'],
|
||||
live_status=LiveStatus(data['live_status']),
|
||||
live_start_time=live_start_time,
|
||||
online=int(data['online']),
|
||||
title=data['title'],
|
||||
cover=data.get('cover', None) or data.get('user_cover', None),
|
||||
tags=data['tags'],
|
||||
description=data['description'],
|
||||
)
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, frozen=True, slots=True)
|
||||
class UserInfo:
|
||||
name: str
|
||||
gender: str
|
||||
face: str
|
||||
uid: int
|
||||
level: int
|
||||
sign: str
|
||||
|
||||
@staticmethod
|
||||
def from_data(data: ResponseData) -> 'UserInfo':
|
||||
return UserInfo(
|
||||
name=data['name'],
|
||||
gender=data['sex'],
|
||||
face=data['face'],
|
||||
uid=data['mid'],
|
||||
level=data['level'],
|
||||
sign=data['sign'],
|
||||
)
|
22
src/blrec/bili/typing.py
Normal file
@ -0,0 +1,22 @@
|
||||
from typing import Any, Dict, Literal, Mapping
|
||||
|
||||
|
||||
Danmaku = Mapping[str, Any]
|
||||
|
||||
QualityNumber = Literal[
|
||||
20000, # 4K
|
||||
10000, # 原画
|
||||
401, # 蓝光(杜比)
|
||||
400, # 蓝光
|
||||
250, # 超清
|
||||
150, # 高清
|
||||
80, # 流畅
|
||||
]
|
||||
|
||||
StreamFormat = Literal[
|
||||
'flv',
|
||||
'fmp4',
|
||||
]
|
||||
|
||||
JsonResponse = Dict[str, Any]
|
||||
ResponseData = Dict[str, Any]
|
0
src/blrec/cli/__init__.py
Normal file
98
src/blrec/cli/main.py
Normal file
@ -0,0 +1,98 @@
|
||||
import os
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from typing import Optional
|
||||
|
||||
import uvicorn
|
||||
from uvicorn.config import LOGGING_CONFIG
|
||||
import typer
|
||||
|
||||
from .. import __prog__, __version__
|
||||
from ..logging import TqdmOutputStream
|
||||
from ..setting import DEFAULT_SETTINGS_PATH
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
cli = typer.Typer()
|
||||
|
||||
|
||||
def version_callback(value: bool) -> None:
|
||||
if value:
|
||||
typer.echo(f'Bilibili live streaming recorder {__version__}')
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
@cli.command()
|
||||
def cli_main(
|
||||
version: Optional[bool] = typer.Option(
|
||||
None,
|
||||
'--version',
|
||||
callback=version_callback,
|
||||
is_eager=True,
|
||||
help=f"show {__prog__}'s version and exit",
|
||||
),
|
||||
config: str = typer.Option(
|
||||
'~/.blrec/settings.toml',
|
||||
'--config',
|
||||
'-c',
|
||||
help='path of setting file',
|
||||
),
|
||||
out_dir: Optional[str] = typer.Option(
|
||||
None,
|
||||
'--out-dir',
|
||||
'-o',
|
||||
help='path of directory to save files (overwrite setting)'
|
||||
),
|
||||
host: str = typer.Option('127.0.0.1', help='webapp host bind'),
|
||||
port: int = typer.Option(2233, help='webapp port bind'),
|
||||
open: bool = typer.Option(False, help='open webapp in default browser'),
|
||||
key_file: Optional[str] = typer.Option(None, help='SSL key file'),
|
||||
cert_file: Optional[str] = typer.Option(None, help='SSL certificate file'),
|
||||
api_key: Optional[str] = typer.Option(None, help='web api key'),
|
||||
) -> None:
|
||||
"""Bilibili live streaming recorder"""
|
||||
if config != DEFAULT_SETTINGS_PATH:
|
||||
os.environ['config'] = config
|
||||
if api_key is not None:
|
||||
os.environ['api_key'] = api_key
|
||||
if out_dir is not None:
|
||||
os.environ['out_dir'] = out_dir
|
||||
|
||||
if open:
|
||||
typer.launch(f'http://{host}:{port}')
|
||||
|
||||
logging_config = deepcopy(LOGGING_CONFIG)
|
||||
logging_config['handlers']['default']['stream'] = TqdmOutputStream
|
||||
logging_config['handlers']['access']['stream'] = TqdmOutputStream
|
||||
|
||||
uvicorn.run(
|
||||
'blrec.web:app',
|
||||
host=host,
|
||||
port=port,
|
||||
ssl_keyfile=key_file,
|
||||
ssl_certfile=cert_file,
|
||||
log_config=logging_config,
|
||||
log_level='info',
|
||||
access_log=False,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
cli()
|
||||
except KeyboardInterrupt:
|
||||
return 1
|
||||
except SystemExit:
|
||||
return 1
|
||||
except BaseException as e:
|
||||
logger.exception(e)
|
||||
return 2
|
||||
else:
|
||||
return 0
|
||||
finally:
|
||||
logger.info('Exit')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
7
src/blrec/core/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
from .recorder import Recorder, RecorderEventListener
|
||||
|
||||
|
||||
__all__ = (
|
||||
'Recorder',
|
||||
'RecorderEventListener',
|
||||
)
|
156
src/blrec/core/danmaku_dumper.py
Normal file
@ -0,0 +1,156 @@
|
||||
import html
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
from typing import Iterator, List
|
||||
|
||||
|
||||
from .. import __version__, __prog__
|
||||
from .danmaku_receiver import DanmakuReceiver, DanmuMsg
|
||||
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
||||
from .statistics import StatisticsCalculator
|
||||
from ..bili.live import Live
|
||||
from ..exception import exception_callback
|
||||
from ..path import danmaku_path
|
||||
from ..danmaku.models import Metadata, Danmu
|
||||
from ..danmaku.io import DanmakuWriter
|
||||
from ..utils.mixins import SwitchableMixin
|
||||
from ..logging.room_id import aio_task_with_room_id
|
||||
|
||||
|
||||
__all__ = 'DanmakuDumper',
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
|
||||
def __init__(
|
||||
self,
|
||||
live: Live,
|
||||
stream_recorder: StreamRecorder,
|
||||
danmaku_receiver: DanmakuReceiver,
|
||||
*,
|
||||
danmu_uname: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
self._live = live
|
||||
self._stream_recorder = stream_recorder
|
||||
self._receiver = danmaku_receiver
|
||||
|
||||
self.danmu_uname = danmu_uname
|
||||
|
||||
self._files: List[str] = []
|
||||
self._calculator = StatisticsCalculator(interval=60)
|
||||
|
||||
@property
|
||||
def danmu_count(self) -> int:
|
||||
return self._calculator.count
|
||||
|
||||
@property
|
||||
def danmu_rate(self) -> float:
|
||||
return self._calculator.rate
|
||||
|
||||
@property
|
||||
def elapsed(self) -> float:
|
||||
return self._calculator.elapsed
|
||||
|
||||
def _do_enable(self) -> None:
|
||||
self._stream_recorder.add_listener(self)
|
||||
logger.debug('Enabled danmaku dumper')
|
||||
|
||||
def _do_disable(self) -> None:
|
||||
self._stream_recorder.remove_listener(self)
|
||||
logger.debug('Disabled danmaku dumper')
|
||||
|
||||
def set_live_start_time(self, time: int) -> None:
|
||||
self._live_start_time = time
|
||||
|
||||
def has_file(self) -> bool:
|
||||
return bool(self._files)
|
||||
|
||||
def get_files(self) -> Iterator[str]:
|
||||
for file in self._files:
|
||||
yield file
|
||||
|
||||
def clear_files(self) -> None:
|
||||
self._files.clear()
|
||||
|
||||
async def on_video_file_created(
|
||||
self, video_path: str, record_start_time: int
|
||||
) -> None:
|
||||
self._path = danmaku_path(video_path)
|
||||
self._record_start_time = record_start_time
|
||||
self._files.append(self._path)
|
||||
self._start_dumping()
|
||||
|
||||
async def on_video_file_completed(self, video_path: str) -> None:
|
||||
await self._stop_dumping()
|
||||
|
||||
def _start_dumping(self) -> None:
|
||||
self._create_dump_task()
|
||||
|
||||
async def _stop_dumping(self) -> None:
|
||||
await self._cancel_dump_task()
|
||||
|
||||
def _create_dump_task(self) -> None:
|
||||
self._dump_task = asyncio.create_task(self._dump())
|
||||
self._dump_task.add_done_callback(exception_callback)
|
||||
|
||||
async def _cancel_dump_task(self) -> None:
|
||||
self._dump_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._dump_task
|
||||
|
||||
@aio_task_with_room_id
|
||||
async def _dump(self) -> None:
|
||||
logger.debug('Started dumping danmaku')
|
||||
self._calculator.reset()
|
||||
|
||||
try:
|
||||
async with DanmakuWriter(self._path) as writer:
|
||||
logger.info(f"Danmaku file created: '{self._path}'")
|
||||
await writer.write_metadata(self._make_metadata())
|
||||
|
||||
while True:
|
||||
msg = await self._receiver.get_message()
|
||||
await writer.write_danmu(self._make_danmu(msg))
|
||||
self._calculator.submit(1)
|
||||
finally:
|
||||
logger.info(f"Danmaku file completed: '{self._path}'")
|
||||
logger.debug('Stopped dumping danmaku')
|
||||
self._calculator.freeze()
|
||||
|
||||
def _make_metadata(self) -> Metadata:
|
||||
return Metadata(
|
||||
user_name=self._live.user_info.name,
|
||||
room_id=self._live.room_info.room_id,
|
||||
room_title=self._live.room_info.title,
|
||||
area=self._live.room_info.area_name,
|
||||
parent_area=self._live.room_info.parent_area_name,
|
||||
live_start_time=self._live.room_info.live_start_time,
|
||||
record_start_time=self._record_start_time,
|
||||
recorder=f'{__prog__} {__version__}',
|
||||
)
|
||||
|
||||
def _make_danmu(self, msg: DanmuMsg) -> Danmu:
|
||||
stime = max((msg.date - self._record_start_time * 1000), 0) / 1000
|
||||
|
||||
if self.danmu_uname:
|
||||
text = f'{msg.uname}: {msg.text}'
|
||||
else:
|
||||
text = msg.text
|
||||
text = html.escape(text)
|
||||
|
||||
return Danmu(
|
||||
stime=stime,
|
||||
mode=msg.mode,
|
||||
size=msg.size,
|
||||
color=msg.color,
|
||||
date=msg.date,
|
||||
pool=msg.pool,
|
||||
uid=msg.uid,
|
||||
dmid=msg.dmid,
|
||||
text=text,
|
||||
)
|
52
src/blrec/core/danmaku_receiver.py
Normal file
@ -0,0 +1,52 @@
|
||||
import logging
|
||||
from asyncio import Queue, QueueFull
|
||||
from typing import Final
|
||||
|
||||
|
||||
from .models import DanmuMsg
|
||||
from ..bili.danmaku_client import (
|
||||
DanmakuClient, DanmakuListener, DanmakuCommand
|
||||
)
|
||||
from ..bili.typing import Danmaku
|
||||
from ..utils.mixins import StoppableMixin
|
||||
|
||||
|
||||
__all__ = 'DanmakuReceiver',
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DanmakuReceiver(DanmakuListener, StoppableMixin):
|
||||
_MAX_QUEUE_SIZE: Final[int] = 2000
|
||||
|
||||
def __init__(self, danmaku_client: DanmakuClient) -> None:
|
||||
super().__init__()
|
||||
self._danmaku_client = danmaku_client
|
||||
self._queue: Queue[DanmuMsg] = Queue(maxsize=self._MAX_QUEUE_SIZE)
|
||||
|
||||
def _do_start(self) -> None:
|
||||
self._danmaku_client.add_listener(self)
|
||||
logger.debug('Started danmaku receiver')
|
||||
|
||||
def _do_stop(self) -> None:
|
||||
self._danmaku_client.remove_listener(self)
|
||||
self._clear_queue()
|
||||
logger.debug('Stopped danmaku receiver')
|
||||
|
||||
async def get_message(self) -> DanmuMsg:
|
||||
return await self._queue.get()
|
||||
|
||||
async def on_danmaku_received(self, danmu: Danmaku) -> None:
|
||||
if danmu['cmd'] != DanmakuCommand.DANMU_MSG.value:
|
||||
return
|
||||
|
||||
danmu_msg = DanmuMsg.from_cmd(danmu)
|
||||
try:
|
||||
self._queue.put_nowait(danmu_msg)
|
||||
except QueueFull:
|
||||
self._queue.get_nowait() # discard the first item
|
||||
self._queue.put_nowait(danmu_msg)
|
||||
|
||||
def _clear_queue(self) -> None:
|
||||
self._queue = Queue(maxsize=self._MAX_QUEUE_SIZE)
|
36
src/blrec/core/models.py
Normal file
@ -0,0 +1,36 @@
|
||||
import logging
|
||||
|
||||
import attr
|
||||
|
||||
from ..bili.typing import Danmaku
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, frozen=True, slots=True)
|
||||
class DanmuMsg:
|
||||
mode: int
|
||||
size: int # font size
|
||||
color: int
|
||||
date: int # a timestamp in miliseconds
|
||||
dmid: int
|
||||
pool: int
|
||||
uid: str # midHash
|
||||
text: str
|
||||
uname: str # sender name
|
||||
|
||||
@staticmethod
|
||||
def from_cmd(danmu: Danmaku) -> 'DanmuMsg':
|
||||
info = danmu['info']
|
||||
return DanmuMsg(
|
||||
mode=int(info[0][1]),
|
||||
size=int(info[0][2]),
|
||||
color=int(info[0][3]),
|
||||
date=int(info[0][4]),
|
||||
dmid=int(info[0][5]),
|
||||
pool=int(info[0][6]),
|
||||
uid=info[0][7],
|
||||
text=info[1],
|
||||
uname=info[2][1],
|
||||
)
|
307
src/blrec/core/recorder.py
Normal file
@ -0,0 +1,307 @@
|
||||
from __future__ import annotations
|
||||
import re
|
||||
import html
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Iterator, Optional
|
||||
|
||||
import humanize
|
||||
|
||||
from .danmaku_receiver import DanmakuReceiver
|
||||
from .danmaku_dumper import DanmakuDumper
|
||||
from .stream_recorder import StreamRecorder
|
||||
from ..event.event_emitter import EventListener, EventEmitter
|
||||
from ..bili.live import Live
|
||||
from ..bili.models import RoomInfo
|
||||
from ..bili.danmaku_client import DanmakuClient
|
||||
from ..bili.live_monitor import LiveMonitor, LiveEventListener
|
||||
from ..bili.typing import QualityNumber
|
||||
from ..utils.mixins import AsyncStoppableMixin
|
||||
|
||||
|
||||
__all__ = 'RecorderEventListener', 'Recorder'
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecorderEventListener(EventListener):
|
||||
async def on_recording_started(self, recorder: Recorder) -> None:
|
||||
...
|
||||
|
||||
async def on_recording_finished(self, recorder: Recorder) -> None:
|
||||
...
|
||||
|
||||
async def on_recording_cancelled(self, recorder: Recorder) -> None:
|
||||
...
|
||||
|
||||
|
||||
class Recorder(
|
||||
EventEmitter[RecorderEventListener], LiveEventListener, AsyncStoppableMixin
|
||||
):
|
||||
def __init__(
|
||||
self,
|
||||
live: Live,
|
||||
danmaku_client: DanmakuClient,
|
||||
live_monitor: LiveMonitor,
|
||||
out_dir: str,
|
||||
path_template: str,
|
||||
*,
|
||||
buffer_size: Optional[int] = None,
|
||||
read_timeout: Optional[int] = None,
|
||||
danmu_uname: bool = False,
|
||||
filesize_limit: int = 0,
|
||||
duration_limit: int = 0,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
self._live = live
|
||||
self._danmaku_client = danmaku_client
|
||||
self._live_monitor = live_monitor
|
||||
|
||||
self._recording: bool = False
|
||||
|
||||
self._stream_recorder = StreamRecorder(
|
||||
self._live,
|
||||
out_dir=out_dir,
|
||||
path_template=path_template,
|
||||
buffer_size=buffer_size,
|
||||
read_timeout=read_timeout,
|
||||
filesize_limit=filesize_limit,
|
||||
duration_limit=duration_limit,
|
||||
)
|
||||
self._danmaku_receiver = DanmakuReceiver(danmaku_client)
|
||||
self._danmaku_dumper = DanmakuDumper(
|
||||
self._live,
|
||||
self._stream_recorder,
|
||||
self._danmaku_receiver,
|
||||
danmu_uname=danmu_uname,
|
||||
)
|
||||
|
||||
@property
|
||||
def recording(self) -> bool:
|
||||
return self._recording
|
||||
|
||||
@property
|
||||
def quality_number(self) -> QualityNumber:
|
||||
return self._stream_recorder.quality_number
|
||||
|
||||
@quality_number.setter
|
||||
def quality_number(self, value: QualityNumber) -> None:
|
||||
self._stream_recorder.quality_number = value
|
||||
|
||||
@property
|
||||
def real_quality_number(self) -> QualityNumber:
|
||||
return self._stream_recorder.real_quality_number
|
||||
|
||||
@property
|
||||
def buffer_size(self) -> int:
|
||||
return self._stream_recorder.buffer_size
|
||||
|
||||
@buffer_size.setter
|
||||
def buffer_size(self, value: int) -> None:
|
||||
self._stream_recorder.buffer_size = value
|
||||
|
||||
@property
|
||||
def read_timeout(self) -> int:
|
||||
return self._stream_recorder.read_timeout
|
||||
|
||||
@read_timeout.setter
|
||||
def read_timeout(self, value: int) -> None:
|
||||
self._stream_recorder.read_timeout = value
|
||||
|
||||
@property
|
||||
def danmu_uname(self) -> bool:
|
||||
return self._danmaku_dumper.danmu_uname
|
||||
|
||||
@danmu_uname.setter
|
||||
def danmu_uname(self, value: bool) -> None:
|
||||
self._danmaku_dumper.danmu_uname = value
|
||||
|
||||
@property
|
||||
def elapsed(self) -> float:
|
||||
return self._stream_recorder.elapsed
|
||||
|
||||
@property
|
||||
def data_count(self) -> int:
|
||||
return self._stream_recorder.data_count
|
||||
|
||||
@property
|
||||
def data_rate(self) -> float:
|
||||
return self._stream_recorder.data_rate
|
||||
|
||||
@property
|
||||
def danmu_count(self) -> int:
|
||||
return self._danmaku_dumper.danmu_count
|
||||
|
||||
@property
|
||||
def danmu_rate(self) -> float:
|
||||
return self._danmaku_dumper.danmu_rate
|
||||
|
||||
@property
|
||||
def out_dir(self) -> str:
|
||||
return self._stream_recorder.out_dir
|
||||
|
||||
@out_dir.setter
|
||||
def out_dir(self, value: str) -> None:
|
||||
self._stream_recorder.out_dir = value
|
||||
|
||||
@property
|
||||
def path_template(self) -> str:
|
||||
return self._stream_recorder.path_template
|
||||
|
||||
@path_template.setter
|
||||
def path_template(self, value: str) -> None:
|
||||
self._stream_recorder.path_template = value
|
||||
|
||||
@property
|
||||
def filesize_limit(self) -> int:
|
||||
return self._stream_recorder.filesize_limit
|
||||
|
||||
@filesize_limit.setter
|
||||
def filesize_limit(self, value: int) -> None:
|
||||
self._stream_recorder.filesize_limit = value
|
||||
|
||||
@property
|
||||
def duration_limit(self) -> int:
|
||||
return self._stream_recorder.duration_limit
|
||||
|
||||
@duration_limit.setter
|
||||
def duration_limit(self, value: int) -> None:
|
||||
self._stream_recorder.duration_limit = value
|
||||
|
||||
async def _do_start(self) -> None:
|
||||
self._live_monitor.add_listener(self)
|
||||
logger.debug('Started recorder')
|
||||
|
||||
self._print_live_info()
|
||||
if self._live.is_living():
|
||||
await self._start_recording(stream_available=True)
|
||||
else:
|
||||
self._print_waiting_message()
|
||||
|
||||
async def _do_stop(self) -> None:
|
||||
await self._stop_recording()
|
||||
self._live_monitor.remove_listener(self)
|
||||
logger.debug('Stopped recorder')
|
||||
|
||||
def get_video_files(self) -> Iterator[str]:
|
||||
yield from self._stream_recorder.get_files()
|
||||
|
||||
def get_danmaku_files(self) -> Iterator[str]:
|
||||
yield from self._danmaku_dumper.get_files()
|
||||
|
||||
async def on_live_began(self, live: Live) -> None:
|
||||
logger.info('The live has began')
|
||||
self._print_live_info()
|
||||
await self._start_recording()
|
||||
|
||||
async def on_live_ended(self, live: Live) -> None:
|
||||
logger.info('The live has ended')
|
||||
await self._stop_recording()
|
||||
self._print_waiting_message()
|
||||
|
||||
async def on_live_stream_available(self, live: Live) -> None:
|
||||
logger.debug('The live stream becomes available')
|
||||
await self._stream_recorder.start()
|
||||
|
||||
async def on_live_stream_reset(self, live: Live) -> None:
|
||||
logger.warning('The live stream has been reset')
|
||||
|
||||
async def on_room_changed(self, room_info: RoomInfo) -> None:
|
||||
self._print_changed_room_info(room_info)
|
||||
self._stream_recorder.update_progress_bar_info()
|
||||
|
||||
async def _start_recording(self, stream_available: bool = False) -> None:
|
||||
if self._recording:
|
||||
return
|
||||
self._recording = True
|
||||
|
||||
self._danmaku_dumper.enable()
|
||||
self._danmaku_receiver.start()
|
||||
await self._prepare()
|
||||
if stream_available:
|
||||
await self._stream_recorder.start()
|
||||
|
||||
logger.info('Started recording')
|
||||
await self._emit('recording_started', self)
|
||||
|
||||
async def _stop_recording(self) -> None:
|
||||
if not self._recording:
|
||||
return
|
||||
self._recording = False
|
||||
|
||||
await self._stream_recorder.stop()
|
||||
self._danmaku_dumper.disable()
|
||||
self._danmaku_receiver.stop()
|
||||
|
||||
if self._stopped:
|
||||
logger.info('Recording Cancelled')
|
||||
await self._emit('recording_cancelled', self)
|
||||
else:
|
||||
logger.info('Recording Finished')
|
||||
await self._emit('recording_finished', self)
|
||||
|
||||
async def _prepare(self) -> None:
|
||||
live_start_time = self._live.room_info.live_start_time
|
||||
self._danmaku_dumper.set_live_start_time(live_start_time)
|
||||
self._danmaku_dumper.clear_files()
|
||||
self._stream_recorder.clear_files()
|
||||
|
||||
def _print_waiting_message(self) -> None:
|
||||
logger.info('Waiting... until the live starts')
|
||||
|
||||
def _print_live_info(self) -> None:
|
||||
room_info = self._live.room_info
|
||||
user_info = self._live.user_info
|
||||
|
||||
if room_info.live_start_time > 0:
|
||||
live_start_time = str(
|
||||
datetime.fromtimestamp(room_info.live_start_time)
|
||||
)
|
||||
else:
|
||||
live_start_time = 'NULL'
|
||||
|
||||
desc = re.sub(
|
||||
r'</?[a-zA-Z][a-zA-Z\d]*[^<>]*>',
|
||||
'',
|
||||
re.sub(r'<br\s*/?>', '\n', html.unescape(room_info.description))
|
||||
).strip()
|
||||
|
||||
msg = f"""
|
||||
================================== User Info ==================================
|
||||
user name : {user_info.name}
|
||||
gender : {user_info.gender}
|
||||
sign : {user_info.sign}
|
||||
uid : {user_info.uid}
|
||||
level : {user_info.level}
|
||||
---------------------------------- Room Info ----------------------------------
|
||||
title : {room_info.title}
|
||||
cover : {room_info.cover}
|
||||
online : {humanize.intcomma(room_info.online)}
|
||||
live status : {room_info.live_status.name}
|
||||
live start time : {live_start_time}
|
||||
room id : {room_info.room_id}
|
||||
short room id : {room_info.short_room_id or 'NULL'}
|
||||
area id : {room_info.area_id}
|
||||
area name : {room_info.area_name}
|
||||
parent area id : {room_info.parent_area_id}
|
||||
parent area name : {room_info.parent_area_name}
|
||||
tags : {room_info.tags}
|
||||
description :
|
||||
{desc}
|
||||
===============================================================================
|
||||
"""
|
||||
logger.info(msg)
|
||||
|
||||
def _print_changed_room_info(self, room_info: RoomInfo) -> None:
|
||||
msg = f"""
|
||||
================================= Room Change =================================
|
||||
title : {room_info.title}
|
||||
area id :{room_info.area_id}
|
||||
area name : {room_info.area_name}
|
||||
parent area id : {room_info.parent_area_id}
|
||||
parent area name : {room_info.parent_area_name}
|
||||
===============================================================================
|
||||
"""
|
||||
logger.info(msg)
|
62
src/blrec/core/retry.py
Normal file
@ -0,0 +1,62 @@
|
||||
import logging
|
||||
from typing import Any, Callable, Optional, Type, cast
|
||||
|
||||
from tenacity import wait_exponential, RetryCallState
|
||||
from tenacity import _utils
|
||||
from tenacity import compat as _compat
|
||||
|
||||
|
||||
class wait_exponential_for_same_exceptions(wait_exponential):
|
||||
"""Wait strategy that applies exponential backoff only for same
|
||||
continuing exceptions.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
multiplier: float = 1,
|
||||
max: float = _utils.MAX_WAIT,
|
||||
exp_base: int = 2,
|
||||
min: float = 0,
|
||||
continuing_criteria: float = 5.0,
|
||||
) -> None:
|
||||
super().__init__(multiplier, max, exp_base, min)
|
||||
self._continuing_criteria = continuing_criteria
|
||||
self._prev_exc_type: Optional[Type[BaseException]] = None
|
||||
self._prev_exc_ts: Optional[float] = None
|
||||
self._last_wait_time: float = 0
|
||||
|
||||
@_compat.wait_dunder_call_accept_old_params
|
||||
def __call__(self, retry_state: RetryCallState) -> float:
|
||||
if (
|
||||
retry_state.outcome is not None and
|
||||
(exc := retry_state.outcome.exception())
|
||||
):
|
||||
curr_exc_type = type(exc)
|
||||
curr_exc_ts = cast(float, retry_state.outcome_timestamp)
|
||||
if (
|
||||
curr_exc_type is not self._prev_exc_type or
|
||||
not self._is_continuing(curr_exc_ts)
|
||||
):
|
||||
retry_state.attempt_number = 1
|
||||
self._prev_exc_type = curr_exc_type
|
||||
self._prev_exc_ts = curr_exc_ts
|
||||
|
||||
self._last_wait_time = wait_time = super().__call__(retry_state)
|
||||
return wait_time
|
||||
|
||||
def _is_continuing(self, curr_exc_ts: float) -> bool:
|
||||
assert self._prev_exc_ts is not None
|
||||
return (
|
||||
curr_exc_ts - (self._prev_exc_ts + self._last_wait_time) <
|
||||
self._continuing_criteria
|
||||
)
|
||||
|
||||
|
||||
def before_sleep_log(
|
||||
logger: logging.Logger, log_level: int, name: str = ''
|
||||
) -> Callable[[RetryCallState], Any]:
|
||||
def log_it(retry_state: RetryCallState) -> None:
|
||||
seconds = cast(float, getattr(retry_state.next_action, 'sleep'))
|
||||
logger.log(log_level, 'Retry %s after %s seconds', name, seconds)
|
||||
|
||||
return log_it
|
59
src/blrec/core/statistics.py
Normal file
@ -0,0 +1,59 @@
|
||||
import time
|
||||
|
||||
|
||||
__all__ = 'StatisticsCalculator',
|
||||
|
||||
|
||||
class StatisticsCalculator:
|
||||
def __init__(self, interval: float = 1.0) -> None:
|
||||
self._interval = interval
|
||||
self._frozen = True
|
||||
|
||||
self._count: int = 0
|
||||
self._rate: float = 0.0
|
||||
self._start_time: float = 0.0
|
||||
self._last_time: float = 0.0
|
||||
self._last_count: int = 0
|
||||
self._elapsed: float = 0.0
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return self._count
|
||||
|
||||
@property
|
||||
def rate(self) -> float:
|
||||
if self._frozen:
|
||||
return self._rate
|
||||
|
||||
curr_time = time.monotonic()
|
||||
time_delta = curr_time - self._last_time
|
||||
|
||||
if time_delta >= self._interval:
|
||||
count_delta = self._count - self._last_count
|
||||
self._rate = count_delta / time_delta
|
||||
|
||||
self._last_time = curr_time
|
||||
self._last_count = self._count
|
||||
|
||||
return self._rate
|
||||
|
||||
@property
|
||||
def elapsed(self) -> float:
|
||||
if not self._frozen:
|
||||
self._elapsed = time.monotonic() - self._start_time
|
||||
return self._elapsed
|
||||
|
||||
def freeze(self) -> None:
|
||||
self._frozen = True
|
||||
|
||||
def reset(self) -> None:
|
||||
self._count = self._last_count = 0
|
||||
self._start_time = self._last_time = time.monotonic()
|
||||
self._rate = 0.0
|
||||
self._elapsed = 0.0
|
||||
|
||||
self._frozen = False
|
||||
|
||||
def submit(self, count: int) -> None:
|
||||
if not self._frozen:
|
||||
self._count += count
|
506
src/blrec/core/stream_recorder.py
Normal file
@ -0,0 +1,506 @@
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
import errno
|
||||
import asyncio
|
||||
import logging
|
||||
from threading import Thread
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from collections import OrderedDict
|
||||
|
||||
from typing import Any, BinaryIO, Dict, Iterator, Optional, Tuple
|
||||
|
||||
import aiohttp
|
||||
import requests
|
||||
import urllib3
|
||||
from tqdm import tqdm
|
||||
from rx.subject import Subject
|
||||
from rx.core import Observable
|
||||
from tenacity import (
|
||||
retry,
|
||||
wait_none,
|
||||
wait_fixed,
|
||||
wait_chain,
|
||||
wait_exponential,
|
||||
stop_after_delay,
|
||||
stop_after_attempt,
|
||||
retry_if_result,
|
||||
retry_if_exception,
|
||||
retry_if_exception_type,
|
||||
Retrying,
|
||||
TryAgain,
|
||||
)
|
||||
|
||||
from .. import __version__, __prog__
|
||||
from .retry import wait_exponential_for_same_exceptions, before_sleep_log
|
||||
from .statistics import StatisticsCalculator
|
||||
from ..event.event_emitter import EventListener, EventEmitter
|
||||
from ..bili.live import Live
|
||||
from ..bili.typing import QualityNumber
|
||||
from ..flv.stream_processor import StreamProcessor, BaseOutputFileManager
|
||||
from ..utils.mixins import AsyncCooperationMix, AsyncStoppableMixin
|
||||
from ..flv.exceptions import FlvStreamCorruptedError
|
||||
from ..bili.exceptions import (
|
||||
LiveRoomHidden, LiveRoomLocked, LiveRoomEncrypted, NoStreamUrlAvailable
|
||||
)
|
||||
|
||||
|
||||
__all__ = 'StreamRecorderEventListener', 'StreamRecorder'
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.getLogger(urllib3.__name__).setLevel(logging.WARNING)
|
||||
|
||||
|
||||
class StreamRecorderEventListener(EventListener):
|
||||
async def on_video_file_created(
|
||||
self, path: str, record_start_time: int
|
||||
) -> None:
|
||||
...
|
||||
|
||||
async def on_video_file_completed(self, path: str) -> None:
|
||||
...
|
||||
|
||||
|
||||
class StreamRecorder(
|
||||
EventEmitter[StreamRecorderEventListener],
|
||||
AsyncCooperationMix,
|
||||
AsyncStoppableMixin,
|
||||
):
|
||||
def __init__(
|
||||
self,
|
||||
live: Live,
|
||||
out_dir: str,
|
||||
path_template: str,
|
||||
*,
|
||||
quality_number: QualityNumber = 10000,
|
||||
buffer_size: Optional[int] = None,
|
||||
read_timeout: Optional[int] = None,
|
||||
filesize_limit: int = 0,
|
||||
duration_limit: int = 0,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
self._live = live
|
||||
self._progress_bar: Optional[tqdm] = None
|
||||
self._stream_processor: Optional[StreamProcessor] = None
|
||||
self._calculator = StatisticsCalculator()
|
||||
self._file_manager = OutputFileManager(
|
||||
live, out_dir, path_template, buffer_size
|
||||
)
|
||||
|
||||
self._quality_number = quality_number
|
||||
self._real_quality_number: Optional[QualityNumber] = None
|
||||
self.buffer_size = buffer_size or io.DEFAULT_BUFFER_SIZE # bytes
|
||||
self.read_timeout = read_timeout or 3 # seconds
|
||||
|
||||
self._filesize_limit = filesize_limit or 0
|
||||
self._duration_limit = duration_limit or 0
|
||||
|
||||
def on_file_created(args: Tuple[str, int]) -> None:
|
||||
logger.info(f"Video file created: '{args[0]}'")
|
||||
self._emit_event('video_file_created', *args)
|
||||
|
||||
def on_file_closed(path: str) -> None:
|
||||
logger.info(f"Video file completed: '{path}'")
|
||||
self._emit_event('video_file_completed', path)
|
||||
|
||||
self._file_manager.file_creates.subscribe(on_file_created)
|
||||
self._file_manager.file_closes.subscribe(on_file_closed)
|
||||
|
||||
@property
|
||||
def data_count(self) -> int:
|
||||
return self._calculator.count
|
||||
|
||||
@property
|
||||
def data_rate(self) -> float:
|
||||
return self._calculator.rate
|
||||
|
||||
@property
|
||||
def elapsed(self) -> float:
|
||||
return self._calculator.elapsed
|
||||
|
||||
@property
|
||||
def out_dir(self) -> str:
|
||||
return self._file_manager.out_dir
|
||||
|
||||
@out_dir.setter
|
||||
def out_dir(self, value: str) -> None:
|
||||
self._file_manager.out_dir = value
|
||||
|
||||
@property
|
||||
def path_template(self) -> str:
|
||||
return self._file_manager.path_template
|
||||
|
||||
@path_template.setter
|
||||
def path_template(self, value: str) -> None:
|
||||
self._file_manager.path_template = value
|
||||
|
||||
@property
|
||||
def quality_number(self) -> QualityNumber:
|
||||
return self._quality_number
|
||||
|
||||
@quality_number.setter
|
||||
def quality_number(self, value: QualityNumber) -> None:
|
||||
self._quality_number = value
|
||||
self._real_quality_number = None
|
||||
|
||||
@property
|
||||
def real_quality_number(self) -> QualityNumber:
|
||||
return self._real_quality_number or 10000
|
||||
|
||||
@property
|
||||
def filesize_limit(self) -> int:
|
||||
if self._stream_processor is not None:
|
||||
return self._stream_processor.filesize_limit
|
||||
else:
|
||||
return self._filesize_limit
|
||||
|
||||
@filesize_limit.setter
|
||||
def filesize_limit(self, value: int) -> None:
|
||||
self._filesize_limit = value
|
||||
if self._stream_processor is not None:
|
||||
self._stream_processor.filesize_limit = value
|
||||
|
||||
@property
|
||||
def duration_limit(self) -> int:
|
||||
if self._stream_processor is not None:
|
||||
return self._stream_processor.duration_limit
|
||||
else:
|
||||
return self._duration_limit
|
||||
|
||||
@duration_limit.setter
|
||||
def duration_limit(self, value: int) -> None:
|
||||
self._duration_limit = value
|
||||
if self._stream_processor is not None:
|
||||
self._stream_processor.duration_limit = value
|
||||
|
||||
def has_file(self) -> bool:
|
||||
return self._file_manager.has_file()
|
||||
|
||||
def get_files(self) -> Iterator[str]:
|
||||
yield from self._file_manager.get_files()
|
||||
|
||||
def clear_files(self) -> None:
|
||||
self._file_manager.clear_files()
|
||||
|
||||
def update_progress_bar_info(self) -> None:
|
||||
if self._progress_bar is not None:
|
||||
self._progress_bar.set_postfix_str(self._make_pbar_postfix())
|
||||
|
||||
async def _do_start(self) -> None:
|
||||
self._thread = Thread(
|
||||
target=self._run, name=f'StreamRecorder::{self._live.room_id}'
|
||||
)
|
||||
self._thread.start()
|
||||
logger.debug('Started stream recorder')
|
||||
|
||||
async def _do_stop(self) -> None:
|
||||
logger.debug('Stopping stream recorder...')
|
||||
if self._stream_processor is not None:
|
||||
self._stream_processor.cancel()
|
||||
await self._loop.run_in_executor(None, self._thread.join)
|
||||
logger.debug('Stopped stream recorder')
|
||||
|
||||
def _run(self) -> None:
|
||||
self._calculator.reset()
|
||||
try:
|
||||
with tqdm(
|
||||
desc='Recording',
|
||||
unit='B',
|
||||
unit_scale=True,
|
||||
unit_divisor=1024,
|
||||
postfix=self._make_pbar_postfix(),
|
||||
) as progress_bar:
|
||||
self._progress_bar = progress_bar
|
||||
|
||||
def update_size(size: int) -> None:
|
||||
progress_bar.update(size)
|
||||
self._calculator.submit(size)
|
||||
|
||||
self._stream_processor = StreamProcessor(
|
||||
self._file_manager,
|
||||
filesize_limit=self._filesize_limit,
|
||||
duration_limit=self._duration_limit,
|
||||
metadata=self._make_metadata(),
|
||||
analyse_data=True,
|
||||
dedup_join=True,
|
||||
save_extra_metadata=True,
|
||||
)
|
||||
self._stream_processor.size_updates.subscribe(update_size)
|
||||
|
||||
with requests.Session() as self._session:
|
||||
self._main_loop()
|
||||
except Exception as e:
|
||||
self._handle_exception(e)
|
||||
finally:
|
||||
if self._stream_processor is not None:
|
||||
self._stream_processor.finalize()
|
||||
self._stream_processor = None
|
||||
self._progress_bar = None
|
||||
self._calculator.freeze()
|
||||
|
||||
def _main_loop(self) -> None:
|
||||
for attempt in Retrying(
|
||||
reraise=True,
|
||||
retry=(
|
||||
retry_if_result(lambda r: not self._stopped) |
|
||||
retry_if_exception(lambda e: not isinstance(e, OSError))
|
||||
),
|
||||
wait=wait_exponential_for_same_exceptions(max=60),
|
||||
stop=stop_after_delay(1800),
|
||||
before_sleep=before_sleep_log(logger, logging.DEBUG, 'main_loop'),
|
||||
):
|
||||
with attempt:
|
||||
try:
|
||||
self._streaming_loop()
|
||||
except NoStreamUrlAvailable:
|
||||
logger.debug('No stream url available')
|
||||
if not self._stopped:
|
||||
raise TryAgain
|
||||
except OSError as e:
|
||||
if e.errno == errno.ENOSPC:
|
||||
# OSError(28, 'No space left on device')
|
||||
raise
|
||||
logger.critical(repr(e), exc_info=e)
|
||||
raise TryAgain
|
||||
except LiveRoomHidden:
|
||||
logger.error('The live room has been hidden!')
|
||||
self._stopped = True
|
||||
except LiveRoomLocked:
|
||||
logger.error('The live room has been locked!')
|
||||
self._stopped = True
|
||||
except LiveRoomEncrypted:
|
||||
logger.error('The live room has been encrypted!')
|
||||
self._stopped = True
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise
|
||||
|
||||
def _streaming_loop(self) -> None:
|
||||
url = self._get_live_stream_url()
|
||||
|
||||
while not self._stopped:
|
||||
try:
|
||||
self._streaming(url)
|
||||
except requests.exceptions.HTTPError as e:
|
||||
# frequently occurred when the live just started or ended.
|
||||
logger.debug(repr(e))
|
||||
self._defer_retry(1, 'streaming_loop')
|
||||
# the url may has been forbidden or expired
|
||||
# when the status code is 404 or 403
|
||||
if e.response.status_code in (403, 404):
|
||||
url = self._get_live_stream_url()
|
||||
except requests.exceptions.Timeout as e:
|
||||
logger.warning(repr(e))
|
||||
except urllib3.exceptions.TimeoutError as e:
|
||||
logger.warning(repr(e))
|
||||
except urllib3.exceptions.ProtocolError as e:
|
||||
# ProtocolError('Connection broken: IncompleteRead(
|
||||
logger.warning(repr(e))
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
logger.warning(repr(e))
|
||||
except FlvStreamCorruptedError as e:
|
||||
logger.warning(repr(e))
|
||||
|
||||
def _streaming(self, url: str) -> None:
|
||||
logger.debug('Getting the live stream...')
|
||||
with self._session.get(
|
||||
url,
|
||||
headers=self._live.headers,
|
||||
stream=True,
|
||||
timeout=self.read_timeout,
|
||||
) as response:
|
||||
logger.debug('Response received')
|
||||
response.raise_for_status()
|
||||
|
||||
assert self._stream_processor is not None
|
||||
self._stream_processor.process_stream(
|
||||
io.BufferedReader(
|
||||
ResponseProxy(response.raw), buffer_size=8192
|
||||
)
|
||||
)
|
||||
|
||||
@retry(
|
||||
reraise=True,
|
||||
retry=retry_if_exception_type((
|
||||
asyncio.TimeoutError, aiohttp.ClientError,
|
||||
)),
|
||||
wait=wait_chain(wait_none(), wait_fixed(1)),
|
||||
stop=stop_after_attempt(300),
|
||||
)
|
||||
def _get_live_stream_url(self) -> str:
|
||||
qn = self._real_quality_number or self.quality_number
|
||||
url = self._run_coroutine(self._live.get_live_stream_url(qn, 'flv'))
|
||||
|
||||
if self._real_quality_number is None:
|
||||
if url is None:
|
||||
logger.info(
|
||||
f'The specified video quality ({qn}) is not available, '
|
||||
'using the original video quality (10000) instead.'
|
||||
)
|
||||
self._real_quality_number = 10000
|
||||
raise TryAgain
|
||||
else:
|
||||
logger.info(f'The specified video quality ({qn}) is available')
|
||||
self._real_quality_number = self.quality_number
|
||||
|
||||
assert url is not None
|
||||
logger.debug(f"Got live stream url: '{url}'")
|
||||
|
||||
return url
|
||||
|
||||
def _defer_retry(self, seconds: float, name: str = '') -> None:
|
||||
if seconds <= 0:
|
||||
return
|
||||
logger.debug(f'Retry {name} after {seconds} seconds')
|
||||
time.sleep(seconds)
|
||||
|
||||
def _make_pbar_postfix(self) -> str:
|
||||
return '{room_id} - {user_name}: {room_title}'.format(
|
||||
room_id=self._live.room_info.room_id,
|
||||
user_name=self._live.user_info.name,
|
||||
room_title=self._live.room_info.title,
|
||||
)
|
||||
|
||||
def _make_metadata(self) -> Dict[str, Any]:
|
||||
github_url = 'https://github.com/acgnhiki/blrec'
|
||||
live_start_time = datetime.fromtimestamp(
|
||||
self._live.room_info.live_start_time, timezone(timedelta(hours=8))
|
||||
)
|
||||
|
||||
return {
|
||||
'Title': self._live.room_info.title,
|
||||
'Artist': self._live.user_info.name,
|
||||
'Date': str(live_start_time),
|
||||
'Comment': f'''\
|
||||
B站直播录像
|
||||
主播:{self._live.user_info.name}
|
||||
标题:{self._live.room_info.title}
|
||||
分区:{self._live.room_info.parent_area_name} - {self._live.room_info.area_name}
|
||||
房间号:{self._live.room_info.room_id}
|
||||
开播时间:{live_start_time}
|
||||
录制程序:{__prog__} v{__version__} {github_url}''',
|
||||
'description': OrderedDict({
|
||||
'UserId': str(self._live.user_info.uid),
|
||||
'UserName': self._live.user_info.name,
|
||||
'RoomId': str(self._live.room_info.room_id),
|
||||
'RoomTitle': self._live.room_info.title,
|
||||
'Area': self._live.room_info.area_name,
|
||||
'ParentArea': self._live.room_info.parent_area_name,
|
||||
'LiveStartTime': str(live_start_time),
|
||||
'Recorder': f'{__prog__} v{__version__} {github_url}',
|
||||
})
|
||||
}
|
||||
|
||||
def _emit_event(self, name: str, *args: Any, **kwds: Any) -> None:
|
||||
self._run_coroutine(super()._emit(name, *args, **kwds))
|
||||
|
||||
|
||||
class ResponseProxy(io.RawIOBase):
|
||||
def __init__(self, response: urllib3.HTTPResponse) -> None:
|
||||
self._response = response
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
# always return False to avoid that `ValueError: read of closed file`,
|
||||
# raised from `CHECK_CLOSED(self, "read of closed file")`,
|
||||
# result in losing data those remaining in the buffer.
|
||||
# ref: `https://gihub.com/python/cpython/blob/63298930fb531ba2bb4f23bc3b915dbf1e17e9e1/Modules/_io/bufferedio.c#L882` # noqa
|
||||
return False
|
||||
|
||||
def readable(self) -> bool:
|
||||
return True
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
return self._response.read(size)
|
||||
|
||||
def tell(self) -> int:
|
||||
return self._response.tell()
|
||||
|
||||
def readinto(self, b: Any) -> int:
|
||||
return self._response.readinto(b)
|
||||
|
||||
def close(self) -> None:
|
||||
self._response.close()
|
||||
|
||||
|
||||
class OutputFileManager(BaseOutputFileManager, AsyncCooperationMix):
|
||||
def __init__(
|
||||
self,
|
||||
live: Live,
|
||||
out_dir: str,
|
||||
path_template: str,
|
||||
buffer_size: Optional[int] = None,
|
||||
) -> None:
|
||||
super().__init__(buffer_size)
|
||||
self._live = live
|
||||
|
||||
self.out_dir = out_dir
|
||||
self.path_template = path_template
|
||||
|
||||
self._file_creates = Subject()
|
||||
self._file_closes = Subject()
|
||||
|
||||
@property
|
||||
def file_creates(self) -> Observable:
|
||||
return self._file_creates
|
||||
|
||||
@property
|
||||
def file_closes(self) -> Observable:
|
||||
return self._file_closes
|
||||
|
||||
def create_file(self) -> BinaryIO:
|
||||
self._start_time = self._get_timestamp()
|
||||
file = super().create_file()
|
||||
self._file_creates.on_next((self._curr_path, self._start_time))
|
||||
return file
|
||||
|
||||
def close_file(self) -> None:
|
||||
path = self._curr_path
|
||||
super().close_file()
|
||||
self._file_closes.on_next(path)
|
||||
|
||||
def _get_timestamp(self) -> int:
|
||||
try:
|
||||
return self._get_server_timestamp()
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get server timestamp: {repr(e)}')
|
||||
return self._get_local_timestamp()
|
||||
|
||||
def _get_local_timestamp(self) -> int:
|
||||
return int(time.time())
|
||||
|
||||
@retry(
|
||||
reraise=True,
|
||||
retry=retry_if_exception_type((
|
||||
asyncio.TimeoutError, aiohttp.ClientError,
|
||||
)),
|
||||
wait=wait_exponential(multiplier=0.1, max=1),
|
||||
stop=stop_after_delay(3),
|
||||
)
|
||||
def _get_server_timestamp(self) -> int:
|
||||
return self._run_coroutine(self._live.get_server_timestamp())
|
||||
|
||||
def _make_path(self) -> str:
|
||||
date_time = datetime.fromtimestamp(self._start_time)
|
||||
relpath = self.path_template.format(
|
||||
roomid=self._live.room_id,
|
||||
uname=self._live.user_info.name,
|
||||
title=self._live.room_info.title,
|
||||
area=self._live.room_info.area_name,
|
||||
parent_area=self._live.room_info.parent_area_name,
|
||||
year=date_time.year,
|
||||
month=str(date_time.month).rjust(2, '0'),
|
||||
day=str(date_time.day).rjust(2, '0'),
|
||||
hour=str(date_time.hour).rjust(2, '0'),
|
||||
minute=str(date_time.minute).rjust(2, '0'),
|
||||
second=str(date_time.second).rjust(2, '0'),
|
||||
)
|
||||
|
||||
full_pathname = os.path.abspath(
|
||||
os.path.expanduser(os.path.join(self.out_dir, relpath) + '.flv')
|
||||
)
|
||||
os.makedirs(os.path.dirname(full_pathname), exist_ok=True)
|
||||
|
||||
return full_pathname
|
0
src/blrec/danmaku/__init__.py
Normal file
44
src/blrec/danmaku/combination.py
Normal file
@ -0,0 +1,44 @@
|
||||
import logging
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
from .io import DanmakuReader, DanmakuWriter
|
||||
from .common import copy_damus
|
||||
from .typing import TimebaseType
|
||||
|
||||
|
||||
__all__ = 'TimebaseType', 'DanmakuCombinator'
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DanmakuCombinator:
|
||||
def __init__(
|
||||
self,
|
||||
in_paths: Iterable[str],
|
||||
out_path: str,
|
||||
timebase_type: TimebaseType = TimebaseType.RECORD,
|
||||
) -> None:
|
||||
self._in_paths = list(in_paths)
|
||||
self._out_path = out_path
|
||||
self._timebase_type = timebase_type
|
||||
|
||||
async def combine(self) -> None:
|
||||
async with DanmakuWriter(self._out_path) as writer:
|
||||
writer = writer
|
||||
|
||||
async with DanmakuReader(self._in_paths[0]) as reader:
|
||||
metadata = await reader.read_metadata()
|
||||
await writer.write_metadata(metadata)
|
||||
|
||||
if self._timebase_type == TimebaseType.LIVE:
|
||||
timebase = metadata.live_start_time * 1000
|
||||
else:
|
||||
timebase = metadata.record_start_time * 1000
|
||||
|
||||
await copy_damus(reader, writer, timebase=timebase)
|
||||
|
||||
for path in self._in_paths[1:]:
|
||||
async with DanmakuReader(path) as reader:
|
||||
await copy_damus(reader, writer, timebase=timebase)
|
22
src/blrec/danmaku/common.py
Normal file
@ -0,0 +1,22 @@
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import attr
|
||||
|
||||
from .io import DanmakuReader, DanmakuWriter
|
||||
|
||||
|
||||
async def copy_damus(
|
||||
reader: DanmakuReader,
|
||||
writer: DanmakuWriter,
|
||||
*,
|
||||
timebase: Optional[int] = None, # milliseconds
|
||||
delta: int = 0, # milliseconds
|
||||
) -> None:
|
||||
async for danmu in reader.read_danmus():
|
||||
if timebase is None:
|
||||
stime = max(0, danmu.stime * 1000 + delta) / 1000
|
||||
else:
|
||||
stime = max(0, danmu.date - timebase + delta) / 1000
|
||||
new_danmu = attr.evolve(danmu, stime=stime)
|
||||
await writer.write_danmu(new_danmu)
|
33
src/blrec/danmaku/concatenation.py
Normal file
@ -0,0 +1,33 @@
|
||||
import logging
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
from .io import DanmakuReader, DanmakuWriter
|
||||
from .common import copy_damus
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
__all__ = 'DanmakuConcatenator'
|
||||
|
||||
|
||||
class DanmakuConcatenator:
|
||||
def __init__(
|
||||
self, in_paths: Iterable[str], deltas: Iterable[int], out_path: str
|
||||
) -> None:
|
||||
self._in_paths = list(in_paths)
|
||||
self._deltas = list(deltas)
|
||||
self._out_path = out_path
|
||||
assert len(self._in_paths) == len(self._deltas)
|
||||
|
||||
async def concat(self) -> None:
|
||||
async with DanmakuWriter(self._out_path) as writer:
|
||||
async with DanmakuReader(self._in_paths[0]) as reader:
|
||||
metadata = await reader.read_metadata()
|
||||
await writer.write_metadata(metadata)
|
||||
await copy_damus(reader, writer, delta=self._deltas[0])
|
||||
|
||||
for path, delta in zip(self._in_paths[1:], self._deltas[1:]):
|
||||
async with DanmakuReader(path) as reader:
|
||||
await copy_damus(reader, writer, delta=delta)
|
51
src/blrec/danmaku/helpers.py
Normal file
@ -0,0 +1,51 @@
|
||||
import os
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
from .io import DanmakuReader, DanmakuWriter
|
||||
from .combination import DanmakuCombinator, TimebaseType
|
||||
from .concatenation import DanmakuConcatenator
|
||||
|
||||
|
||||
async def has_danmu(path: str) -> bool:
|
||||
async with DanmakuReader(path) as reader:
|
||||
async for _ in reader.read_danmus():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def clear_danmu(path: str) -> None:
|
||||
async with DanmakuReader(path) as reader:
|
||||
async with DanmakuWriter(path) as writer:
|
||||
metadata = await reader.read_metadata()
|
||||
await writer.write_metadata(metadata)
|
||||
|
||||
|
||||
async def concat_danmaku(
|
||||
in_paths: Iterable[str], deltas: Iterable[int], out_path: str
|
||||
) -> None:
|
||||
concatenator = DanmakuConcatenator(in_paths, deltas, out_path)
|
||||
await concatenator.concat()
|
||||
|
||||
|
||||
async def combine_danmaku(
|
||||
in_paths: Iterable[str],
|
||||
out_path: str,
|
||||
timebase_type: TimebaseType = TimebaseType.RECORD,
|
||||
) -> None:
|
||||
combiator = DanmakuCombinator(in_paths, out_path, timebase_type)
|
||||
await combiator.combine()
|
||||
|
||||
|
||||
async def merge_danmaku(src: str, dst: str, insert: bool = False) -> None:
|
||||
"""Merge all danmus in the src into the dst.
|
||||
|
||||
insert: if true, src insert before dst otherwise src append after dst
|
||||
"""
|
||||
out = dst + '.tmp'
|
||||
if insert:
|
||||
await combine_danmaku([src, dst], out)
|
||||
else:
|
||||
await combine_danmaku([dst, src], out)
|
||||
await clear_danmu(src)
|
||||
os.replace(out, dst)
|
130
src/blrec/danmaku/io.py
Normal file
@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
import html
|
||||
import asyncio
|
||||
from typing import AsyncIterator, Final, List
|
||||
|
||||
from lxml import etree
|
||||
import aiofiles
|
||||
|
||||
from .models import Metadata, Danmu
|
||||
from .typing import Element
|
||||
|
||||
|
||||
__all__ = 'DanmakuReader', 'DanmakuWriter'
|
||||
|
||||
|
||||
class DanmakuReader:
|
||||
def __init__(self, path: str) -> None:
|
||||
self._path = path
|
||||
|
||||
async def __aenter__(self) -> DanmakuReader:
|
||||
await self.init()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb): # type: ignore
|
||||
pass
|
||||
|
||||
async def init(self) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
self._tree = await loop.run_in_executor(None, etree.parse, self._path)
|
||||
|
||||
async def read_metadata(self) -> Metadata:
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, self._read_metadata)
|
||||
|
||||
async def read_danmus(self) -> AsyncIterator[Danmu]:
|
||||
for elem in await self._get_danmu_elems():
|
||||
yield self._make_danmu(elem)
|
||||
|
||||
def _read_metadata(self) -> Metadata:
|
||||
return Metadata(
|
||||
user_name=self._tree.xpath('/i/metadata/user_name')[0].text,
|
||||
room_id=int(self._tree.xpath('/i/metadata/room_id')[0].text),
|
||||
room_title=self._tree.xpath('/i/metadata/room_title')[0].text,
|
||||
area=self._tree.xpath('/i/metadata/area')[0].text,
|
||||
parent_area=self._tree.xpath('/i/metadata/parent_area')[0].text,
|
||||
live_start_time=int(
|
||||
self._tree.xpath('/i/metadata/live_start_time')[0].text
|
||||
),
|
||||
record_start_time=int(
|
||||
self._tree.xpath('/i/metadata/record_start_time')[0].text
|
||||
),
|
||||
recorder=self._tree.xpath('/i/metadata/recorder')[0].text,
|
||||
)
|
||||
|
||||
async def _get_danmu_elems(self) -> List[Element]:
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, self._tree.xpath, '/i/d')
|
||||
|
||||
def _make_danmu(self, elem: Element) -> Danmu:
|
||||
params = elem.get('p').split(',')
|
||||
return Danmu(
|
||||
stime=float(params[0]),
|
||||
mode=int(params[1]),
|
||||
size=int(params[2]),
|
||||
color=int(params[3]),
|
||||
date=int(params[4]),
|
||||
pool=int(params[5]),
|
||||
uid=params[6],
|
||||
dmid=int(params[7]),
|
||||
text=elem.text,
|
||||
)
|
||||
|
||||
|
||||
class DanmakuWriter:
|
||||
_XML_HEAD: Final[str] = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<i>
|
||||
<chatserver>chat.bilibili.com</chatserver>
|
||||
<chatid>0</chatid>
|
||||
<mission>0</mission>
|
||||
<maxlimit>0</maxlimit>
|
||||
<state>0</state>
|
||||
<real_name>0</real_name>
|
||||
<source>e-r</source>
|
||||
"""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self._path = path
|
||||
|
||||
async def __aenter__(self) -> DanmakuWriter:
|
||||
await self.init()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb): # type: ignore
|
||||
await self.complete()
|
||||
|
||||
async def init(self) -> None:
|
||||
self._file = await aiofiles.open(self._path, 'wt', encoding='utf8')
|
||||
await self._file.write(self._XML_HEAD)
|
||||
|
||||
async def write_metadata(self, metadata: Metadata) -> None:
|
||||
await self._file.write(self._serialize_metadata(metadata))
|
||||
|
||||
async def write_danmu(self, danmu: Danmu) -> None:
|
||||
await self._file.write(self._serialize_danmu(danmu))
|
||||
|
||||
async def complete(self) -> None:
|
||||
await self._file.write('</i>')
|
||||
await self._file.close()
|
||||
|
||||
def _serialize_metadata(self, metadata: Metadata) -> str:
|
||||
return f"""\
|
||||
<metadata>
|
||||
<user_name>{html.escape(metadata.user_name)}</user_name>
|
||||
<room_id>{metadata.room_id}</room_id>
|
||||
<room_title>{html.escape(metadata.room_title)}</room_title>
|
||||
<area>{html.escape(metadata.area)}</area>
|
||||
<parent_area>{html.escape(metadata.parent_area)}</parent_area>
|
||||
<live_start_time>{metadata.live_start_time}</live_start_time>
|
||||
<record_start_time>{metadata.record_start_time}</record_start_time>
|
||||
<recorder>{metadata.recorder}</recorder>
|
||||
</metadata>
|
||||
"""
|
||||
|
||||
def _serialize_danmu(self, dm: Danmu) -> str:
|
||||
return (
|
||||
f' <d p="{dm.stime:.5f},{dm.mode},{dm.size},{dm.color},'
|
||||
f'{dm.date},{dm.pool},{dm.uid},{dm.dmid}">'
|
||||
f'{html.escape(dm.text)}</d>\n'
|
||||
)
|
28
src/blrec/danmaku/models.py
Normal file
@ -0,0 +1,28 @@
|
||||
|
||||
|
||||
import attr
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class Metadata:
|
||||
user_name: str
|
||||
room_id: int
|
||||
room_title: str
|
||||
area: str
|
||||
parent_area: str
|
||||
live_start_time: int # seconds
|
||||
record_start_time: int # seconds
|
||||
recorder: str
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class Danmu:
|
||||
stime: float
|
||||
mode: int
|
||||
size: int
|
||||
color: int
|
||||
date: int # milliseconds
|
||||
pool: int
|
||||
uid: str # uid hash
|
||||
dmid: int
|
||||
text: str
|
10
src/blrec/danmaku/typing.py
Normal file
@ -0,0 +1,10 @@
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class TimebaseType(Enum):
|
||||
LIVE = 'live'
|
||||
RECORD = 'record'
|
||||
|
||||
|
||||
Element = Any
|
1
src/blrec/data/webapp/198.20d927ba29c55516dd2e.js
Normal file
478
src/blrec/data/webapp/3rdpartylicenses.txt
Normal file
@ -0,0 +1,478 @@
|
||||
@angular/animations
|
||||
MIT
|
||||
|
||||
@angular/cdk
|
||||
MIT
|
||||
The MIT License
|
||||
|
||||
Copyright (c) 2021 Google LLC.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
|
||||
@angular/common
|
||||
MIT
|
||||
|
||||
@angular/core
|
||||
MIT
|
||||
|
||||
@angular/forms
|
||||
MIT
|
||||
|
||||
@angular/platform-browser
|
||||
MIT
|
||||
|
||||
@angular/router
|
||||
MIT
|
||||
|
||||
@angular/service-worker
|
||||
MIT
|
||||
|
||||
@ant-design/colors
|
||||
MIT
|
||||
MIT LICENSE
|
||||
|
||||
Copyright (c) 2018-present Ant UED, https://xtech.antfin.com/
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
@ant-design/icons-angular
|
||||
MIT
|
||||
|
||||
@babel/runtime
|
||||
MIT
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014-present Sebastian McKenzie and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
@ctrl/tinycolor
|
||||
MIT
|
||||
Copyright (c) Scott Cooper <scttcper@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
filesize
|
||||
BSD-3-Clause
|
||||
Copyright (c) 2021, Jason Mulligan
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of filesize nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
|
||||
lodash-es
|
||||
MIT
|
||||
Copyright OpenJS Foundation and other contributors <https://openjsf.org/>
|
||||
|
||||
Based on Underscore.js, copyright Jeremy Ashkenas,
|
||||
DocumentCloud and Investigative Reporters & Editors <http://underscorejs.org/>
|
||||
|
||||
This software consists of voluntary contributions made by many
|
||||
individuals. For exact contribution history, see the revision history
|
||||
available at https://github.com/lodash/lodash
|
||||
|
||||
The following license applies to all parts of this software except as
|
||||
documented below:
|
||||
|
||||
====
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
====
|
||||
|
||||
Copyright and related rights for sample code are waived via CC0. Sample
|
||||
code is defined as all source code displayed within the prose of the
|
||||
documentation.
|
||||
|
||||
CC0: http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
====
|
||||
|
||||
Files located in the node_modules and vendor directories are externally
|
||||
maintained libraries used by this software which have their own
|
||||
licenses; we recommend you read them, as their terms may differ from the
|
||||
terms above.
|
||||
|
||||
|
||||
ng-zorro-antd
|
||||
MIT
|
||||
|
||||
ngx-logger
|
||||
MIT
|
||||
The MIT License
|
||||
|
||||
Copyright (c) 2018 David Fannin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
|
||||
rxjs
|
||||
Apache-2.0
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright (c) 2015-2018 Google, Inc., Netflix, Inc., Microsoft Corp. and contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
|
||||
|
||||
tslib
|
||||
0BSD
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
vlq
|
||||
MIT
|
||||
Copyright (c) 2017 [these people](https://github.com/Rich-Harris/vlq/graphs/contributors)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
zone.js
|
||||
MIT
|
||||
The MIT License
|
||||
|
||||
Copyright (c) 2010-2020 Google LLC. https://angular.io/license
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
1
src/blrec/data/webapp/51.560338ef5c3689b822f9.js
Normal file
1
src/blrec/data/webapp/659.4923e830b3feb2abcce2.js
Normal file
1
src/blrec/data/webapp/80.78cb9b41766e5c57d657.js
Normal file
1
src/blrec/data/webapp/954.05cbcc74da25eb3ef2a9.js
Normal file
16
src/blrec/data/webapp/assets/animal/panda.js
Normal file
@ -0,0 +1,16 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'animal:panda',
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<path d="M99.096 315.634s-82.58-64.032-82.58-132.13c0-66.064 33.032-165.162 148.646-148.646 83.37 11.91 99.096 165.162 99.096 165.162l-165.162 115.614zM924.906 315.634s82.58-64.032 82.58-132.13c0-66.064-33.032-165.162-148.646-148.646-83.37 11.91-99.096 165.162-99.096 165.162l165.162 115.614z" fill="#6B676E" p-id="1143" />
|
||||
<path d="M1024 561.548c0 264.526-229.23 429.42-512.002 429.42S0 826.076 0 561.548 283.96 66.064 512.002 66.064 1024 297.022 1024 561.548z" fill="#FFEBD2" p-id="1144" />
|
||||
<path d="M330.324 842.126c0 82.096 81.34 148.646 181.678 148.646s181.678-66.55 181.678-148.646H330.324z" fill="#E9D7C3" p-id="1145" />
|
||||
<path d="M644.13 611.098C594.582 528.516 561.55 512 512.002 512c-49.548 0-82.58 16.516-132.13 99.096-42.488 70.814-78.73 211.264-49.548 247.742 66.064 82.58 165.162 33.032 181.678 33.032 16.516 0 115.614 49.548 181.678-33.032 29.18-36.476-7.064-176.93-49.55-247.74z" fill="#FFFFFF" p-id="1146" />
|
||||
<path d="M611.098 495.484c0-45.608 36.974-82.58 82.58-82.58 49.548 0 198.194 99.098 198.194 165.162s-79.934 144.904-148.646 99.096c-49.548-33.032-132.128-148.646-132.128-181.678zM412.904 495.484c0-45.608-36.974-82.58-82.58-82.58-49.548 0-198.194 99.098-198.194 165.162s79.934 144.904 148.646 99.096c49.548-33.032 132.128-148.646 132.128-181.678z" fill="#6B676E" p-id="1147" />
|
||||
<path d="M512.002 726.622c-30.06 0-115.614 5.668-115.614 33.032 0 49.638 105.484 85.24 115.614 82.58 10.128 2.66 115.614-32.944 115.614-82.58-0.002-27.366-85.556-33.032-115.614-33.032z" fill="#464655" p-id="1148" />
|
||||
<path d="M330.324 495.484m-33.032 0a33.032 33.032 0 1 0 66.064 0 33.032 33.032 0 1 0-66.064 0Z" fill="#464655" p-id="1149" />
|
||||
<path d="M693.678 495.484m-33.032 0a33.032 33.032 0 1 0 66.064 0 33.032 33.032 0 1 0-66.064 0Z" fill="#464655" p-id="1150" />
|
||||
</svg>
|
||||
`
|
||||
});
|
||||
})();
|
10
src/blrec/data/webapp/assets/animal/panda.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<path d="M99.096 315.634s-82.58-64.032-82.58-132.13c0-66.064 33.032-165.162 148.646-148.646 83.37 11.91 99.096 165.162 99.096 165.162l-165.162 115.614zM924.906 315.634s82.58-64.032 82.58-132.13c0-66.064-33.032-165.162-148.646-148.646-83.37 11.91-99.096 165.162-99.096 165.162l165.162 115.614z" fill="#6B676E" p-id="1143" />
|
||||
<path d="M1024 561.548c0 264.526-229.23 429.42-512.002 429.42S0 826.076 0 561.548 283.96 66.064 512.002 66.064 1024 297.022 1024 561.548z" fill="#FFEBD2" p-id="1144" />
|
||||
<path d="M330.324 842.126c0 82.096 81.34 148.646 181.678 148.646s181.678-66.55 181.678-148.646H330.324z" fill="#E9D7C3" p-id="1145" />
|
||||
<path d="M644.13 611.098C594.582 528.516 561.55 512 512.002 512c-49.548 0-82.58 16.516-132.13 99.096-42.488 70.814-78.73 211.264-49.548 247.742 66.064 82.58 165.162 33.032 181.678 33.032 16.516 0 115.614 49.548 181.678-33.032 29.18-36.476-7.064-176.93-49.55-247.74z" fill="#FFFFFF" p-id="1146" />
|
||||
<path d="M611.098 495.484c0-45.608 36.974-82.58 82.58-82.58 49.548 0 198.194 99.098 198.194 165.162s-79.934 144.904-148.646 99.096c-49.548-33.032-132.128-148.646-132.128-181.678zM412.904 495.484c0-45.608-36.974-82.58-82.58-82.58-49.548 0-198.194 99.098-198.194 165.162s79.934 144.904 148.646 99.096c49.548-33.032 132.128-148.646 132.128-181.678z" fill="#6B676E" p-id="1147" />
|
||||
<path d="M512.002 726.622c-30.06 0-115.614 5.668-115.614 33.032 0 49.638 105.484 85.24 115.614 82.58 10.128 2.66 115.614-32.944 115.614-82.58-0.002-27.366-85.556-33.032-115.614-33.032z" fill="#464655" p-id="1148" />
|
||||
<path d="M330.324 495.484m-33.032 0a33.032 33.032 0 1 0 66.064 0 33.032 33.032 0 1 0-66.064 0Z" fill="#464655" p-id="1149" />
|
||||
<path d="M693.678 495.484m-33.032 0a33.032 33.032 0 1 0 66.064 0 33.032 33.032 0 1 0-66.064 0Z" fill="#464655" p-id="1150" />
|
||||
</svg>
|
After Width: | Height: | Size: 1.8 KiB |
0
src/blrec/data/webapp/assets/fill/.gitkeep
Normal file
7
src/blrec/data/webapp/assets/fill/account-book.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'account-book',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M880 184H712v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H384v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H144c-17.7 0-32 14.3-32 32v664c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V216c0-17.7-14.3-32-32-32zM648.3 426.8l-87.7 161.1h45.7c5.5 0 10 4.5 10 10v21.3c0 5.5-4.5 10-10 10h-63.4v29.7h63.4c5.5 0 10 4.5 10 10v21.3c0 5.5-4.5 10-10 10h-63.4V752c0 5.5-4.5 10-10 10h-41.3c-5.5 0-10-4.5-10-10v-51.8h-63.1c-5.5 0-10-4.5-10-10v-21.3c0-5.5 4.5-10 10-10h63.1v-29.7h-63.1c-5.5 0-10-4.5-10-10v-21.3c0-5.5 4.5-10 10-10h45.2l-88-161.1c-2.6-4.8-.9-10.9 4-13.6 1.5-.8 3.1-1.2 4.8-1.2h46c3.8 0 7.2 2.1 8.9 5.5l72.9 144.3 73.2-144.3a10 10 0 018.9-5.5h45c5.5 0 10 4.5 10 10 .1 1.7-.3 3.3-1.1 4.8z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/account-book.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M880 184H712v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H384v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H144c-17.7 0-32 14.3-32 32v664c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V216c0-17.7-14.3-32-32-32zM648.3 426.8l-87.7 161.1h45.7c5.5 0 10 4.5 10 10v21.3c0 5.5-4.5 10-10 10h-63.4v29.7h63.4c5.5 0 10 4.5 10 10v21.3c0 5.5-4.5 10-10 10h-63.4V752c0 5.5-4.5 10-10 10h-41.3c-5.5 0-10-4.5-10-10v-51.8h-63.1c-5.5 0-10-4.5-10-10v-21.3c0-5.5 4.5-10 10-10h63.1v-29.7h-63.1c-5.5 0-10-4.5-10-10v-21.3c0-5.5 4.5-10 10-10h45.2l-88-161.1c-2.6-4.8-.9-10.9 4-13.6 1.5-.8 3.1-1.2 4.8-1.2h46c3.8 0 7.2 2.1 8.9 5.5l72.9 144.3 73.2-144.3a10 10 0 018.9-5.5h45c5.5 0 10 4.5 10 10 .1 1.7-.3 3.3-1.1 4.8z" /></svg>
|
After Width: | Height: | Size: 749 B |
7
src/blrec/data/webapp/assets/fill/alert.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'alert',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M512 244c176.18 0 319 142.82 319 319v233a32 32 0 01-32 32H225a32 32 0 01-32-32V563c0-176.18 142.82-319 319-319zM484 68h56a8 8 0 018 8v96a8 8 0 01-8 8h-56a8 8 0 01-8-8V76a8 8 0 018-8zM177.25 191.66a8 8 0 0111.32 0l67.88 67.88a8 8 0 010 11.31l-39.6 39.6a8 8 0 01-11.31 0l-67.88-67.88a8 8 0 010-11.31l39.6-39.6zm669.6 0l39.6 39.6a8 8 0 010 11.3l-67.88 67.9a8 8 0 01-11.32 0l-39.6-39.6a8 8 0 010-11.32l67.89-67.88a8 8 0 0111.31 0zM192 892h640a32 32 0 0132 32v24a8 8 0 01-8 8H168a8 8 0 01-8-8v-24a32 32 0 0132-32zm148-317v253h64V575h-64z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/alert.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M512 244c176.18 0 319 142.82 319 319v233a32 32 0 01-32 32H225a32 32 0 01-32-32V563c0-176.18 142.82-319 319-319zM484 68h56a8 8 0 018 8v96a8 8 0 01-8 8h-56a8 8 0 01-8-8V76a8 8 0 018-8zM177.25 191.66a8 8 0 0111.32 0l67.88 67.88a8 8 0 010 11.31l-39.6 39.6a8 8 0 01-11.31 0l-67.88-67.88a8 8 0 010-11.31l39.6-39.6zm669.6 0l39.6 39.6a8 8 0 010 11.3l-67.88 67.9a8 8 0 01-11.32 0l-39.6-39.6a8 8 0 010-11.32l67.89-67.88a8 8 0 0111.31 0zM192 892h640a32 32 0 0132 32v24a8 8 0 01-8 8H168a8 8 0 01-8-8v-24a32 32 0 0132-32zm148-317v253h64V575h-64z" /></svg>
|
After Width: | Height: | Size: 598 B |
7
src/blrec/data/webapp/assets/fill/alipay-circle.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'alipay-circle',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M308.6 545.7c-19.8 2-57.1 10.7-77.4 28.6-61 53-24.5 150 99 150 71.8 0 143.5-45.7 199.8-119-80.2-38.9-148.1-66.8-221.4-59.6zm460.5 67c100.1 33.4 154.7 43 166.7 44.8A445.9 445.9 0 00960 512c0-247.4-200.6-448-448-448S64 264.6 64 512s200.6 448 448 448c155.9 0 293.2-79.7 373.5-200.5-75.6-29.8-213.6-85-286.8-120.1-69.9 85.7-160.1 137.8-253.7 137.8-158.4 0-212.1-138.1-137.2-229 16.3-19.8 44.2-38.7 87.3-49.4 67.5-16.5 175 10.3 275.7 43.4 18.1-33.3 33.4-69.9 44.7-108.9H305.1V402h160v-56.2H271.3v-31.3h193.8v-80.1s0-13.5 13.7-13.5H557v93.6h191.7v31.3H557.1V402h156.4c-15 61.1-37.7 117.4-66.2 166.8 47.5 17.1 90.1 33.3 121.8 43.9z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/alipay-circle.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M308.6 545.7c-19.8 2-57.1 10.7-77.4 28.6-61 53-24.5 150 99 150 71.8 0 143.5-45.7 199.8-119-80.2-38.9-148.1-66.8-221.4-59.6zm460.5 67c100.1 33.4 154.7 43 166.7 44.8A445.9 445.9 0 00960 512c0-247.4-200.6-448-448-448S64 264.6 64 512s200.6 448 448 448c155.9 0 293.2-79.7 373.5-200.5-75.6-29.8-213.6-85-286.8-120.1-69.9 85.7-160.1 137.8-253.7 137.8-158.4 0-212.1-138.1-137.2-229 16.3-19.8 44.2-38.7 87.3-49.4 67.5-16.5 175 10.3 275.7 43.4 18.1-33.3 33.4-69.9 44.7-108.9H305.1V402h160v-56.2H271.3v-31.3h193.8v-80.1s0-13.5 13.7-13.5H557v93.6h191.7v31.3H557.1V402h156.4c-15 61.1-37.7 117.4-66.2 166.8 47.5 17.1 90.1 33.3 121.8 43.9z" /></svg>
|
After Width: | Height: | Size: 690 B |
7
src/blrec/data/webapp/assets/fill/alipay-square.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'alipay-square',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M308.6 545.7c-19.8 2-57.1 10.7-77.4 28.6-61 53-24.5 150 99 150 71.8 0 143.5-45.7 199.8-119-80.2-38.9-148.1-66.8-221.4-59.6zM880 112H144c-17.7 0-32 14.3-32 32v736c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V144c0-17.7-14.3-32-32-32zm29.4 663.2S703 689.4 598.7 639.5C528.8 725.2 438.6 777.3 345 777.3c-158.4 0-212.1-138.1-137.2-229 16.3-19.8 44.2-38.7 87.3-49.4 67.5-16.5 175 10.3 275.7 43.4 18.1-33.3 33.4-69.9 44.7-108.9H305.1V402h160v-56.2H271.3v-31.3h193.8v-80.1s0-13.5 13.7-13.5H557v93.6h191.7v31.3H557.1V402h156.4c-15 61.1-37.7 117.4-66.2 166.8 47.5 17.1 90.1 33.3 121.8 43.9 114.3 38.2 140.2 40.2 140.2 40.2v122.3z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/alipay-square.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M308.6 545.7c-19.8 2-57.1 10.7-77.4 28.6-61 53-24.5 150 99 150 71.8 0 143.5-45.7 199.8-119-80.2-38.9-148.1-66.8-221.4-59.6zM880 112H144c-17.7 0-32 14.3-32 32v736c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V144c0-17.7-14.3-32-32-32zm29.4 663.2S703 689.4 598.7 639.5C528.8 725.2 438.6 777.3 345 777.3c-158.4 0-212.1-138.1-137.2-229 16.3-19.8 44.2-38.7 87.3-49.4 67.5-16.5 175 10.3 275.7 43.4 18.1-33.3 33.4-69.9 44.7-108.9H305.1V402h160v-56.2H271.3v-31.3h193.8v-80.1s0-13.5 13.7-13.5H557v93.6h191.7v31.3H557.1V402h156.4c-15 61.1-37.7 117.4-66.2 166.8 47.5 17.1 90.1 33.3 121.8 43.9 114.3 38.2 140.2 40.2 140.2 40.2v122.3z" /></svg>
|
After Width: | Height: | Size: 687 B |
7
src/blrec/data/webapp/assets/fill/aliwangwang.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'aliwangwang',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M868.2 377.4c-18.9-45.1-46.3-85.6-81.2-120.6a377.26 377.26 0 00-120.5-81.2A375.65 375.65 0 00519 145.8c-41.9 0-82.9 6.7-121.9 20C306 123.3 200.8 120 170.6 120c-2.2 0-7.4 0-9.4.2-11.9.4-22.8 6.5-29.2 16.4-6.5 9.9-7.7 22.4-3.4 33.5l64.3 161.6a378.59 378.59 0 00-52.8 193.2c0 51.4 10 101 29.8 147.6 18.9 45 46.2 85.6 81.2 120.5 34.7 34.8 75.4 62.1 120.5 81.2C418.3 894 467.9 904 519 904c51.3 0 100.9-10 147.7-29.8 44.9-18.9 85.5-46.3 120.4-81.2 34.7-34.8 62.1-75.4 81.2-120.6a376.5 376.5 0 0029.8-147.6c-.2-51.2-10.1-100.8-29.9-147.4zm-325.2 79c0 20.4-16.6 37.1-37.1 37.1-20.4 0-37.1-16.7-37.1-37.1v-55.1c0-20.4 16.6-37.1 37.1-37.1 20.4 0 37.1 16.6 37.1 37.1v55.1zm175.2 0c0 20.4-16.6 37.1-37.1 37.1S644 476.8 644 456.4v-55.1c0-20.4 16.7-37.1 37.1-37.1 20.4 0 37.1 16.6 37.1 37.1v55.1z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/aliwangwang.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M868.2 377.4c-18.9-45.1-46.3-85.6-81.2-120.6a377.26 377.26 0 00-120.5-81.2A375.65 375.65 0 00519 145.8c-41.9 0-82.9 6.7-121.9 20C306 123.3 200.8 120 170.6 120c-2.2 0-7.4 0-9.4.2-11.9.4-22.8 6.5-29.2 16.4-6.5 9.9-7.7 22.4-3.4 33.5l64.3 161.6a378.59 378.59 0 00-52.8 193.2c0 51.4 10 101 29.8 147.6 18.9 45 46.2 85.6 81.2 120.5 34.7 34.8 75.4 62.1 120.5 81.2C418.3 894 467.9 904 519 904c51.3 0 100.9-10 147.7-29.8 44.9-18.9 85.5-46.3 120.4-81.2 34.7-34.8 62.1-75.4 81.2-120.6a376.5 376.5 0 0029.8-147.6c-.2-51.2-10.1-100.8-29.9-147.4zm-325.2 79c0 20.4-16.6 37.1-37.1 37.1-20.4 0-37.1-16.7-37.1-37.1v-55.1c0-20.4 16.6-37.1 37.1-37.1 20.4 0 37.1 16.6 37.1 37.1v55.1zm175.2 0c0 20.4-16.6 37.1-37.1 37.1S644 476.8 644 456.4v-55.1c0-20.4 16.7-37.1 37.1-37.1 20.4 0 37.1 16.6 37.1 37.1v55.1z" /></svg>
|
After Width: | Height: | Size: 848 B |
7
src/blrec/data/webapp/assets/fill/amazon-circle.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'amazon-circle',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M485 467.5c-11.6 4.9-20.9 12.2-27.8 22-6.9 9.8-10.4 21.6-10.4 35.5 0 17.8 7.5 31.5 22.4 41.2 14.1 9.1 28.9 11.4 44.4 6.8 17.9-5.2 30-17.9 36.4-38.1 3-9.3 4.5-19.7 4.5-31.3v-50.2c-12.6.4-24.4 1.6-35.5 3.7-11.1 2.1-22.4 5.6-34 10.4zM512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm35.8 262.7c-7.2-10.9-20.1-16.4-38.7-16.4-1.3 0-3 .1-5.3.3-2.2.2-6.6 1.5-12.9 3.7a79.4 79.4 0 00-17.9 9.1c-5.5 3.8-11.5 10-18 18.4-6.4 8.5-11.5 18.4-15.3 29.8l-94-8.4c0-12.4 2.4-24.7 7-36.9 4.7-12.2 11.8-23.9 21.4-35 9.6-11.2 21.1-21 34.5-29.4 13.4-8.5 29.6-15.2 48.4-20.3 18.9-5.1 39.1-7.6 60.9-7.6 21.3 0 40.6 2.6 57.8 7.7 17.2 5.2 31.1 11.5 41.4 19.1a117 117 0 0125.9 25.7c6.9 9.6 11.7 18.5 14.4 26.7 2.7 8.2 4 15.7 4 22.8v182.5c0 6.4 1.4 13 4.3 19.8 2.9 6.8 6.3 12.8 10.2 18 3.9 5.2 7.9 9.9 12 14.3 4.1 4.3 7.6 7.7 10.6 9.9l4.1 3.4-72.5 69.4c-8.5-7.7-16.9-15.4-25.2-23.4-8.3-8-14.5-14-18.5-18.1l-6.1-6.2c-2.4-2.3-5-5.7-8-10.2-8.1 12.2-18.5 22.8-31.1 31.8-12.7 9-26.3 15.6-40.7 19.7-14.5 4.1-29.4 6.5-44.7 7.1-15.3.6-30-1.5-43.9-6.5-13.9-5-26.5-11.7-37.6-20.3-11.1-8.6-19.9-20.2-26.5-35-6.6-14.8-9.9-31.5-9.9-50.4 0-17.4 3-33.3 8.9-47.7 6-14.5 13.6-26.5 23-36.1 9.4-9.6 20.7-18.2 34-25.7s26.4-13.4 39.2-17.7c12.8-4.2 26.6-7.8 41.5-10.7 14.9-2.9 27.6-4.8 38.2-5.7 10.6-.9 21.2-1.6 31.8-2v-39.4c0-13.5-2.3-23.5-6.7-30.1zm180.5 379.6c-2.8 3.3-7.5 7.8-14.1 13.5s-16.8 12.7-30.5 21.1c-13.7 8.4-28.8 16-45 22.9-16.3 6.9-36.3 12.9-60.1 18-23.7 5.1-48.2 7.6-73.3 7.6-25.4 0-50.7-3.2-76.1-9.6-25.4-6.4-47.6-14.3-66.8-23.7-19.1-9.4-37.6-20.2-55.1-32.2-17.6-12.1-31.7-22.9-42.4-32.5-10.6-9.6-19.6-18.7-26.8-27.1-1.7-1.9-2.8-3.6-3.2-5.1-.4-1.5-.3-2.8.3-3.7.6-.9 1.5-1.6 2.6-2.2a7.42 7.42 0 017.4.8c40.9 24.2 72.9 41.3 95.9 51.4 82.9 36.4 168 45.7 255.3 27.9 40.5-8.3 82.1-22.2 124.9-41.8 3.2-1.2 6-1.5 8.3-.9 2.3.6 3.5 2.4 3.5 5.4 0 2.8-1.6 6.3-4.8 10.2zm59.9-29c-1.8 11.1-4.9 21.6-9.1 31.8-7.2 17.1-16.3 30-27.1 38.4-3.6 2.9-6.4 3.8-8.3 2.8-1.9-1-1.9-3.5 0-7.4 4.5-9.3 9.2-21.8 14.2-37.7 5-15.8 5.7-26 2.1-30.5-1.1-1.5-2.7-2.6-5-3.6-2.2-.9-5.1-1.5-8.6-1.9s-6.7-.6-9.4-.8c-2.8-.2-6.5-.2-11.2 0-4.7.2-8 .4-10.1.6a874.4 874.4 0 01-17.1 1.5c-1.3.2-2.7.4-4.1.5-1.5.1-2.7.2-3.5.3l-2.7.3c-1 .1-1.7.2-2.2.2h-3.2l-1-.2-.6-.5-.5-.9c-1.3-3.3 3.7-7.4 15-12.4s22.3-8.1 32.9-9.3c9.8-1.5 21.3-1.5 34.5-.3s21.3 3.7 24.3 7.4c2.3 3.5 2.5 10.7.7 21.7z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/amazon-circle.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M485 467.5c-11.6 4.9-20.9 12.2-27.8 22-6.9 9.8-10.4 21.6-10.4 35.5 0 17.8 7.5 31.5 22.4 41.2 14.1 9.1 28.9 11.4 44.4 6.8 17.9-5.2 30-17.9 36.4-38.1 3-9.3 4.5-19.7 4.5-31.3v-50.2c-12.6.4-24.4 1.6-35.5 3.7-11.1 2.1-22.4 5.6-34 10.4zM512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm35.8 262.7c-7.2-10.9-20.1-16.4-38.7-16.4-1.3 0-3 .1-5.3.3-2.2.2-6.6 1.5-12.9 3.7a79.4 79.4 0 00-17.9 9.1c-5.5 3.8-11.5 10-18 18.4-6.4 8.5-11.5 18.4-15.3 29.8l-94-8.4c0-12.4 2.4-24.7 7-36.9 4.7-12.2 11.8-23.9 21.4-35 9.6-11.2 21.1-21 34.5-29.4 13.4-8.5 29.6-15.2 48.4-20.3 18.9-5.1 39.1-7.6 60.9-7.6 21.3 0 40.6 2.6 57.8 7.7 17.2 5.2 31.1 11.5 41.4 19.1a117 117 0 0125.9 25.7c6.9 9.6 11.7 18.5 14.4 26.7 2.7 8.2 4 15.7 4 22.8v182.5c0 6.4 1.4 13 4.3 19.8 2.9 6.8 6.3 12.8 10.2 18 3.9 5.2 7.9 9.9 12 14.3 4.1 4.3 7.6 7.7 10.6 9.9l4.1 3.4-72.5 69.4c-8.5-7.7-16.9-15.4-25.2-23.4-8.3-8-14.5-14-18.5-18.1l-6.1-6.2c-2.4-2.3-5-5.7-8-10.2-8.1 12.2-18.5 22.8-31.1 31.8-12.7 9-26.3 15.6-40.7 19.7-14.5 4.1-29.4 6.5-44.7 7.1-15.3.6-30-1.5-43.9-6.5-13.9-5-26.5-11.7-37.6-20.3-11.1-8.6-19.9-20.2-26.5-35-6.6-14.8-9.9-31.5-9.9-50.4 0-17.4 3-33.3 8.9-47.7 6-14.5 13.6-26.5 23-36.1 9.4-9.6 20.7-18.2 34-25.7s26.4-13.4 39.2-17.7c12.8-4.2 26.6-7.8 41.5-10.7 14.9-2.9 27.6-4.8 38.2-5.7 10.6-.9 21.2-1.6 31.8-2v-39.4c0-13.5-2.3-23.5-6.7-30.1zm180.5 379.6c-2.8 3.3-7.5 7.8-14.1 13.5s-16.8 12.7-30.5 21.1c-13.7 8.4-28.8 16-45 22.9-16.3 6.9-36.3 12.9-60.1 18-23.7 5.1-48.2 7.6-73.3 7.6-25.4 0-50.7-3.2-76.1-9.6-25.4-6.4-47.6-14.3-66.8-23.7-19.1-9.4-37.6-20.2-55.1-32.2-17.6-12.1-31.7-22.9-42.4-32.5-10.6-9.6-19.6-18.7-26.8-27.1-1.7-1.9-2.8-3.6-3.2-5.1-.4-1.5-.3-2.8.3-3.7.6-.9 1.5-1.6 2.6-2.2a7.42 7.42 0 017.4.8c40.9 24.2 72.9 41.3 95.9 51.4 82.9 36.4 168 45.7 255.3 27.9 40.5-8.3 82.1-22.2 124.9-41.8 3.2-1.2 6-1.5 8.3-.9 2.3.6 3.5 2.4 3.5 5.4 0 2.8-1.6 6.3-4.8 10.2zm59.9-29c-1.8 11.1-4.9 21.6-9.1 31.8-7.2 17.1-16.3 30-27.1 38.4-3.6 2.9-6.4 3.8-8.3 2.8-1.9-1-1.9-3.5 0-7.4 4.5-9.3 9.2-21.8 14.2-37.7 5-15.8 5.7-26 2.1-30.5-1.1-1.5-2.7-2.6-5-3.6-2.2-.9-5.1-1.5-8.6-1.9s-6.7-.6-9.4-.8c-2.8-.2-6.5-.2-11.2 0-4.7.2-8 .4-10.1.6a874.4 874.4 0 01-17.1 1.5c-1.3.2-2.7.4-4.1.5-1.5.1-2.7.2-3.5.3l-2.7.3c-1 .1-1.7.2-2.2.2h-3.2l-1-.2-.6-.5-.5-.9c-1.3-3.3 3.7-7.4 15-12.4s22.3-8.1 32.9-9.3c9.8-1.5 21.3-1.5 34.5-.3s21.3 3.7 24.3 7.4c2.3 3.5 2.5 10.7.7 21.7z" /></svg>
|
After Width: | Height: | Size: 2.3 KiB |
7
src/blrec/data/webapp/assets/fill/amazon-square.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'amazon-square',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M880 112H144c-17.7 0-32 14.3-32 32v736c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V144c0-17.7-14.3-32-32-32zM547.8 326.7c-7.2-10.9-20.1-16.4-38.7-16.4-1.3 0-3 .1-5.3.3-2.2.2-6.6 1.5-12.9 3.7a79.4 79.4 0 00-17.9 9.1c-5.5 3.8-11.5 10-18 18.4-6.4 8.5-11.5 18.4-15.3 29.8l-94-8.4c0-12.4 2.4-24.7 7-36.9s11.8-23.9 21.4-35c9.6-11.2 21.1-21 34.5-29.4 13.4-8.5 29.6-15.2 48.4-20.3 18.9-5.1 39.1-7.6 60.9-7.6 21.3 0 40.6 2.6 57.8 7.7 17.2 5.2 31.1 11.5 41.4 19.1a117 117 0 0125.9 25.7c6.9 9.6 11.7 18.5 14.4 26.7 2.7 8.2 4 15.7 4 22.8v182.5c0 6.4 1.4 13 4.3 19.8 2.9 6.8 6.3 12.8 10.2 18 3.9 5.2 7.9 9.9 12 14.3 4.1 4.3 7.6 7.7 10.6 9.9l4.1 3.4-72.5 69.4c-8.5-7.7-16.9-15.4-25.2-23.4-8.3-8-14.5-14-18.5-18.1l-6.1-6.2c-2.4-2.3-5-5.7-8-10.2-8.1 12.2-18.5 22.8-31.1 31.8-12.7 9-26.3 15.6-40.7 19.7-14.5 4.1-29.4 6.5-44.7 7.1-15.3.6-30-1.5-43.9-6.5-13.9-5-26.5-11.7-37.6-20.3-11.1-8.6-19.9-20.2-26.5-35-6.6-14.8-9.9-31.5-9.9-50.4 0-17.4 3-33.3 8.9-47.7 6-14.5 13.6-26.5 23-36.1 9.4-9.6 20.7-18.2 34-25.7s26.4-13.4 39.2-17.7c12.8-4.2 26.6-7.8 41.5-10.7 14.9-2.9 27.6-4.8 38.2-5.7 10.6-.9 21.2-1.6 31.8-2v-39.4c0-13.5-2.3-23.5-6.7-30.1zm180.5 379.6c-2.8 3.3-7.5 7.8-14.1 13.5s-16.8 12.7-30.5 21.1c-13.7 8.4-28.8 16-45 22.9-16.3 6.9-36.3 12.9-60.1 18-23.7 5.1-48.2 7.6-73.3 7.6-25.4 0-50.7-3.2-76.1-9.6-25.4-6.4-47.6-14.3-66.8-23.7-19.1-9.4-37.6-20.2-55.1-32.2-17.6-12.1-31.7-22.9-42.4-32.5-10.6-9.6-19.6-18.7-26.8-27.1-1.7-1.9-2.8-3.6-3.2-5.1-.4-1.5-.3-2.8.3-3.7.6-.9 1.5-1.6 2.6-2.2a7.42 7.42 0 017.4.8c40.9 24.2 72.9 41.3 95.9 51.4 82.9 36.4 168 45.7 255.3 27.9 40.5-8.3 82.1-22.2 124.9-41.8 3.2-1.2 6-1.5 8.3-.9 2.3.6 3.5 2.4 3.5 5.4 0 2.8-1.6 6.3-4.8 10.2zm59.9-29c-1.8 11.1-4.9 21.6-9.1 31.8-7.2 17.1-16.3 30-27.1 38.4-3.6 2.9-6.4 3.8-8.3 2.8-1.9-1-1.9-3.5 0-7.4 4.5-9.3 9.2-21.8 14.2-37.7 5-15.8 5.7-26 2.1-30.5-1.1-1.5-2.7-2.6-5-3.6-2.2-.9-5.1-1.5-8.6-1.9s-6.7-.6-9.4-.8c-2.8-.2-6.5-.2-11.2 0-4.7.2-8 .4-10.1.6a874.4 874.4 0 01-17.1 1.5c-1.3.2-2.7.4-4.1.5-1.5.1-2.7.2-3.5.3l-2.7.3c-1 .1-1.7.2-2.2.2h-3.2l-1-.2-.6-.5-.5-.9c-1.3-3.3 3.7-7.4 15-12.4s22.3-8.1 32.9-9.3c9.8-1.5 21.3-1.5 34.5-.3s21.3 3.7 24.3 7.4c2.3 3.5 2.5 10.7.7 21.7zM485 467.5c-11.6 4.9-20.9 12.2-27.8 22-6.9 9.8-10.4 21.6-10.4 35.5 0 17.8 7.5 31.5 22.4 41.2 14.1 9.1 28.9 11.4 44.4 6.8 17.9-5.2 30-17.9 36.4-38.1 3-9.3 4.5-19.7 4.5-31.3v-50.2c-12.6.4-24.4 1.6-35.5 3.7-11.1 2.1-22.4 5.6-34 10.4z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/amazon-square.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M880 112H144c-17.7 0-32 14.3-32 32v736c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V144c0-17.7-14.3-32-32-32zM547.8 326.7c-7.2-10.9-20.1-16.4-38.7-16.4-1.3 0-3 .1-5.3.3-2.2.2-6.6 1.5-12.9 3.7a79.4 79.4 0 00-17.9 9.1c-5.5 3.8-11.5 10-18 18.4-6.4 8.5-11.5 18.4-15.3 29.8l-94-8.4c0-12.4 2.4-24.7 7-36.9s11.8-23.9 21.4-35c9.6-11.2 21.1-21 34.5-29.4 13.4-8.5 29.6-15.2 48.4-20.3 18.9-5.1 39.1-7.6 60.9-7.6 21.3 0 40.6 2.6 57.8 7.7 17.2 5.2 31.1 11.5 41.4 19.1a117 117 0 0125.9 25.7c6.9 9.6 11.7 18.5 14.4 26.7 2.7 8.2 4 15.7 4 22.8v182.5c0 6.4 1.4 13 4.3 19.8 2.9 6.8 6.3 12.8 10.2 18 3.9 5.2 7.9 9.9 12 14.3 4.1 4.3 7.6 7.7 10.6 9.9l4.1 3.4-72.5 69.4c-8.5-7.7-16.9-15.4-25.2-23.4-8.3-8-14.5-14-18.5-18.1l-6.1-6.2c-2.4-2.3-5-5.7-8-10.2-8.1 12.2-18.5 22.8-31.1 31.8-12.7 9-26.3 15.6-40.7 19.7-14.5 4.1-29.4 6.5-44.7 7.1-15.3.6-30-1.5-43.9-6.5-13.9-5-26.5-11.7-37.6-20.3-11.1-8.6-19.9-20.2-26.5-35-6.6-14.8-9.9-31.5-9.9-50.4 0-17.4 3-33.3 8.9-47.7 6-14.5 13.6-26.5 23-36.1 9.4-9.6 20.7-18.2 34-25.7s26.4-13.4 39.2-17.7c12.8-4.2 26.6-7.8 41.5-10.7 14.9-2.9 27.6-4.8 38.2-5.7 10.6-.9 21.2-1.6 31.8-2v-39.4c0-13.5-2.3-23.5-6.7-30.1zm180.5 379.6c-2.8 3.3-7.5 7.8-14.1 13.5s-16.8 12.7-30.5 21.1c-13.7 8.4-28.8 16-45 22.9-16.3 6.9-36.3 12.9-60.1 18-23.7 5.1-48.2 7.6-73.3 7.6-25.4 0-50.7-3.2-76.1-9.6-25.4-6.4-47.6-14.3-66.8-23.7-19.1-9.4-37.6-20.2-55.1-32.2-17.6-12.1-31.7-22.9-42.4-32.5-10.6-9.6-19.6-18.7-26.8-27.1-1.7-1.9-2.8-3.6-3.2-5.1-.4-1.5-.3-2.8.3-3.7.6-.9 1.5-1.6 2.6-2.2a7.42 7.42 0 017.4.8c40.9 24.2 72.9 41.3 95.9 51.4 82.9 36.4 168 45.7 255.3 27.9 40.5-8.3 82.1-22.2 124.9-41.8 3.2-1.2 6-1.5 8.3-.9 2.3.6 3.5 2.4 3.5 5.4 0 2.8-1.6 6.3-4.8 10.2zm59.9-29c-1.8 11.1-4.9 21.6-9.1 31.8-7.2 17.1-16.3 30-27.1 38.4-3.6 2.9-6.4 3.8-8.3 2.8-1.9-1-1.9-3.5 0-7.4 4.5-9.3 9.2-21.8 14.2-37.7 5-15.8 5.7-26 2.1-30.5-1.1-1.5-2.7-2.6-5-3.6-2.2-.9-5.1-1.5-8.6-1.9s-6.7-.6-9.4-.8c-2.8-.2-6.5-.2-11.2 0-4.7.2-8 .4-10.1.6a874.4 874.4 0 01-17.1 1.5c-1.3.2-2.7.4-4.1.5-1.5.1-2.7.2-3.5.3l-2.7.3c-1 .1-1.7.2-2.2.2h-3.2l-1-.2-.6-.5-.5-.9c-1.3-3.3 3.7-7.4 15-12.4s22.3-8.1 32.9-9.3c9.8-1.5 21.3-1.5 34.5-.3s21.3 3.7 24.3 7.4c2.3 3.5 2.5 10.7.7 21.7zM485 467.5c-11.6 4.9-20.9 12.2-27.8 22-6.9 9.8-10.4 21.6-10.4 35.5 0 17.8 7.5 31.5 22.4 41.2 14.1 9.1 28.9 11.4 44.4 6.8 17.9-5.2 30-17.9 36.4-38.1 3-9.3 4.5-19.7 4.5-31.3v-50.2c-12.6.4-24.4 1.6-35.5 3.7-11.1 2.1-22.4 5.6-34 10.4z" /></svg>
|
After Width: | Height: | Size: 2.4 KiB |
7
src/blrec/data/webapp/assets/fill/android.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'android',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M270.1 741.7c0 23.4 19.1 42.5 42.6 42.5h48.7v120.4c0 30.5 24.5 55.4 54.6 55.4 30.2 0 54.6-24.8 54.6-55.4V784.1h85v120.4c0 30.5 24.5 55.4 54.6 55.4 30.2 0 54.6-24.8 54.6-55.4V784.1h48.7c23.5 0 42.6-19.1 42.6-42.5V346.4h-486v395.3zm357.1-600.1l44.9-65c2.6-3.8 2-8.9-1.5-11.4-3.5-2.4-8.5-1.2-11.1 2.6l-46.6 67.6c-30.7-12.1-64.9-18.8-100.8-18.8-35.9 0-70.1 6.7-100.8 18.8l-46.6-67.5c-2.6-3.8-7.6-5.1-11.1-2.6-3.5 2.4-4.1 7.4-1.5 11.4l44.9 65c-71.4 33.2-121.4 96.1-127.8 169.6h486c-6.6-73.6-56.7-136.5-128-169.7zM409.5 244.1a26.9 26.9 0 1126.9-26.9 26.97 26.97 0 01-26.9 26.9zm208.4 0a26.9 26.9 0 1126.9-26.9 26.97 26.97 0 01-26.9 26.9zm223.4 100.7c-30.2 0-54.6 24.8-54.6 55.4v216.4c0 30.5 24.5 55.4 54.6 55.4 30.2 0 54.6-24.8 54.6-55.4V400.1c.1-30.6-24.3-55.3-54.6-55.3zm-658.6 0c-30.2 0-54.6 24.8-54.6 55.4v216.4c0 30.5 24.5 55.4 54.6 55.4 30.2 0 54.6-24.8 54.6-55.4V400.1c0-30.6-24.5-55.3-54.6-55.3z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/android.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M270.1 741.7c0 23.4 19.1 42.5 42.6 42.5h48.7v120.4c0 30.5 24.5 55.4 54.6 55.4 30.2 0 54.6-24.8 54.6-55.4V784.1h85v120.4c0 30.5 24.5 55.4 54.6 55.4 30.2 0 54.6-24.8 54.6-55.4V784.1h48.7c23.5 0 42.6-19.1 42.6-42.5V346.4h-486v395.3zm357.1-600.1l44.9-65c2.6-3.8 2-8.9-1.5-11.4-3.5-2.4-8.5-1.2-11.1 2.6l-46.6 67.6c-30.7-12.1-64.9-18.8-100.8-18.8-35.9 0-70.1 6.7-100.8 18.8l-46.6-67.5c-2.6-3.8-7.6-5.1-11.1-2.6-3.5 2.4-4.1 7.4-1.5 11.4l44.9 65c-71.4 33.2-121.4 96.1-127.8 169.6h486c-6.6-73.6-56.7-136.5-128-169.7zM409.5 244.1a26.9 26.9 0 1126.9-26.9 26.97 26.97 0 01-26.9 26.9zm208.4 0a26.9 26.9 0 1126.9-26.9 26.97 26.97 0 01-26.9 26.9zm223.4 100.7c-30.2 0-54.6 24.8-54.6 55.4v216.4c0 30.5 24.5 55.4 54.6 55.4 30.2 0 54.6-24.8 54.6-55.4V400.1c.1-30.6-24.3-55.3-54.6-55.3zm-658.6 0c-30.2 0-54.6 24.8-54.6 55.4v216.4c0 30.5 24.5 55.4 54.6 55.4 30.2 0 54.6-24.8 54.6-55.4V400.1c0-30.6-24.5-55.3-54.6-55.3z" /></svg>
|
After Width: | Height: | Size: 963 B |
7
src/blrec/data/webapp/assets/fill/api.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'api',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M917.7 148.8l-42.4-42.4c-1.6-1.6-3.6-2.3-5.7-2.3s-4.1.8-5.7 2.3l-76.1 76.1a199.27 199.27 0 00-112.1-34.3c-51.2 0-102.4 19.5-141.5 58.6L432.3 308.7a8.03 8.03 0 000 11.3L704 591.7c1.6 1.6 3.6 2.3 5.7 2.3 2 0 4.1-.8 5.7-2.3l101.9-101.9c68.9-69 77-175.7 24.3-253.5l76.1-76.1c3.1-3.2 3.1-8.3 0-11.4zM578.9 546.7a8.03 8.03 0 00-11.3 0L501 613.3 410.7 523l66.7-66.7c3.1-3.1 3.1-8.2 0-11.3L441 408.6a8.03 8.03 0 00-11.3 0L363 475.3l-43-43a7.85 7.85 0 00-5.7-2.3c-2 0-4.1.8-5.7 2.3L206.8 534.2c-68.9 68.9-77 175.7-24.3 253.5l-76.1 76.1a8.03 8.03 0 000 11.3l42.4 42.4c1.6 1.6 3.6 2.3 5.7 2.3s4.1-.8 5.7-2.3l76.1-76.1c33.7 22.9 72.9 34.3 112.1 34.3 51.2 0 102.4-19.5 141.5-58.6l101.9-101.9c3.1-3.1 3.1-8.2 0-11.3l-43-43 66.7-66.7c3.1-3.1 3.1-8.2 0-11.3l-36.6-36.2z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/api.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M917.7 148.8l-42.4-42.4c-1.6-1.6-3.6-2.3-5.7-2.3s-4.1.8-5.7 2.3l-76.1 76.1a199.27 199.27 0 00-112.1-34.3c-51.2 0-102.4 19.5-141.5 58.6L432.3 308.7a8.03 8.03 0 000 11.3L704 591.7c1.6 1.6 3.6 2.3 5.7 2.3 2 0 4.1-.8 5.7-2.3l101.9-101.9c68.9-69 77-175.7 24.3-253.5l76.1-76.1c3.1-3.2 3.1-8.3 0-11.4zM578.9 546.7a8.03 8.03 0 00-11.3 0L501 613.3 410.7 523l66.7-66.7c3.1-3.1 3.1-8.2 0-11.3L441 408.6a8.03 8.03 0 00-11.3 0L363 475.3l-43-43a7.85 7.85 0 00-5.7-2.3c-2 0-4.1.8-5.7 2.3L206.8 534.2c-68.9 68.9-77 175.7-24.3 253.5l-76.1 76.1a8.03 8.03 0 000 11.3l42.4 42.4c1.6 1.6 3.6 2.3 5.7 2.3s4.1-.8 5.7-2.3l76.1-76.1c33.7 22.9 72.9 34.3 112.1 34.3 51.2 0 102.4-19.5 141.5-58.6l101.9-101.9c3.1-3.1 3.1-8.2 0-11.3l-43-43 66.7-66.7c3.1-3.1 3.1-8.2 0-11.3l-36.6-36.2z" /></svg>
|
After Width: | Height: | Size: 819 B |
7
src/blrec/data/webapp/assets/fill/apple.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'apple',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M747.4 535.7c-.4-68.2 30.5-119.6 92.9-157.5-34.9-50-87.7-77.5-157.3-82.8-65.9-5.2-138 38.4-164.4 38.4-27.9 0-91.7-36.6-141.9-36.6C273.1 298.8 163 379.8 163 544.6c0 48.7 8.9 99 26.7 150.8 23.8 68.2 109.6 235.3 199.1 232.6 46.8-1.1 79.9-33.2 140.8-33.2 59.1 0 89.7 33.2 141.9 33.2 90.3-1.3 167.9-153.2 190.5-221.6-121.1-57.1-114.6-167.2-114.6-170.7zm-105.1-305c50.7-60.2 46.1-115 44.6-134.7-44.8 2.6-96.6 30.5-126.1 64.8-32.5 36.8-51.6 82.3-47.5 133.6 48.4 3.7 92.6-21.2 129-63.7z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/apple.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M747.4 535.7c-.4-68.2 30.5-119.6 92.9-157.5-34.9-50-87.7-77.5-157.3-82.8-65.9-5.2-138 38.4-164.4 38.4-27.9 0-91.7-36.6-141.9-36.6C273.1 298.8 163 379.8 163 544.6c0 48.7 8.9 99 26.7 150.8 23.8 68.2 109.6 235.3 199.1 232.6 46.8-1.1 79.9-33.2 140.8-33.2 59.1 0 89.7 33.2 141.9 33.2 90.3-1.3 167.9-153.2 190.5-221.6-121.1-57.1-114.6-167.2-114.6-170.7zm-105.1-305c50.7-60.2 46.1-115 44.6-134.7-44.8 2.6-96.6 30.5-126.1 64.8-32.5 36.8-51.6 82.3-47.5 133.6 48.4 3.7 92.6-21.2 129-63.7z" /></svg>
|
After Width: | Height: | Size: 544 B |
7
src/blrec/data/webapp/assets/fill/appstore.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'appstore',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M864 144H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm0 400H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zM464 144H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm0 400H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/appstore.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M864 144H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm0 400H560c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16zM464 144H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V160c0-8.8-7.2-16-16-16zm0 400H160c-8.8 0-16 7.2-16 16v304c0 8.8 7.2 16 16 16h304c8.8 0 16-7.2 16-16V560c0-8.8-7.2-16-16-16z" /></svg>
|
After Width: | Height: | Size: 470 B |
7
src/blrec/data/webapp/assets/fill/audio.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'audio',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M512 624c93.9 0 170-75.2 170-168V232c0-92.8-76.1-168-170-168s-170 75.2-170 168v224c0 92.8 76.1 168 170 168zm330-170c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8 0 140.3-113.7 254-254 254S258 594.3 258 454c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8 0 168.7 126.6 307.9 290 327.6V884H326.7c-13.7 0-24.7 14.3-24.7 32v36c0 4.4 2.8 8 6.2 8h407.6c3.4 0 6.2-3.6 6.2-8v-36c0-17.7-11-32-24.7-32H548V782.1c165.3-18 294-158 294-328.1z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/audio.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M512 624c93.9 0 170-75.2 170-168V232c0-92.8-76.1-168-170-168s-170 75.2-170 168v224c0 92.8 76.1 168 170 168zm330-170c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8 0 140.3-113.7 254-254 254S258 594.3 258 454c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8 0 168.7 126.6 307.9 290 327.6V884H326.7c-13.7 0-24.7 14.3-24.7 32v36c0 4.4 2.8 8 6.2 8h407.6c3.4 0 6.2-3.6 6.2-8v-36c0-17.7-11-32-24.7-32H548V782.1c165.3-18 294-158 294-328.1z" /></svg>
|
After Width: | Height: | Size: 475 B |
7
src/blrec/data/webapp/assets/fill/backward.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'backward',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="0 0 1024 1024" focusable="false"><path d="M485.6 249.9L198.2 498c-8.3 7.1-8.3 20.8 0 27.9l287.4 248.2c10.7 9.2 26.4.9 26.4-14V263.8c0-14.8-15.7-23.2-26.4-13.9zm320 0L518.2 498a18.6 18.6 0 00-6.2 14c0 5.2 2.1 10.4 6.2 14l287.4 248.2c10.7 9.2 26.4.9 26.4-14V263.8c0-14.8-15.7-23.2-26.4-13.9z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/backward.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 1024 1024" focusable="false"><path d="M485.6 249.9L198.2 498c-8.3 7.1-8.3 20.8 0 27.9l287.4 248.2c10.7 9.2 26.4.9 26.4-14V263.8c0-14.8-15.7-23.2-26.4-13.9zm320 0L518.2 498a18.6 18.6 0 00-6.2 14c0 5.2 2.1 10.4 6.2 14l287.4 248.2c10.7 9.2 26.4.9 26.4-14V263.8c0-14.8-15.7-23.2-26.4-13.9z" /></svg>
|
After Width: | Height: | Size: 313 B |
7
src/blrec/data/webapp/assets/fill/bank.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'bank',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M894 462c30.9 0 43.8-39.7 18.7-58L530.8 126.2a31.81 31.81 0 00-37.6 0L111.3 404c-25.1 18.2-12.2 58 18.8 58H192v374h-72c-4.4 0-8 3.6-8 8v52c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-52c0-4.4-3.6-8-8-8h-72V462h62zM381 836H264V462h117v374zm189 0H453V462h117v374zm190 0H642V462h118v374z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/bank.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M894 462c30.9 0 43.8-39.7 18.7-58L530.8 126.2a31.81 31.81 0 00-37.6 0L111.3 404c-25.1 18.2-12.2 58 18.8 58H192v374h-72c-4.4 0-8 3.6-8 8v52c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-52c0-4.4-3.6-8-8-8h-72V462h62zM381 836H264V462h117v374zm189 0H453V462h117v374zm190 0H642V462h118v374z" /></svg>
|
After Width: | Height: | Size: 343 B |
7
src/blrec/data/webapp/assets/fill/behance-circle.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'behance-circle',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M420.3 470.3c8.7-6.3 12.9-16.7 12.9-31 .3-6.8-1.1-13.5-4.1-19.6-2.7-4.9-6.7-9-11.6-11.9a44.8 44.8 0 00-16.6-6c-6.4-1.2-12.9-1.8-19.3-1.7h-70.3v79.7h76.1c13.1.1 24.2-3.1 32.9-9.5zm11.8 72c-9.8-7.5-22.9-11.2-39.2-11.2h-81.8v94h80.2c7.5 0 14.4-.7 21.1-2.1a50.5 50.5 0 0017.8-7.2c5.1-3.3 9.2-7.8 12.3-13.6 3-5.8 4.5-13.2 4.5-22.1 0-17.7-5-30.2-14.9-37.8zM512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm86.5 286.9h138.4v33.7H598.5v-33.7zM512 628.8a89.52 89.52 0 01-27 31c-11.8 8.2-24.9 14.2-38.8 17.7a167.4 167.4 0 01-44.6 5.7H236V342.1h161c16.3 0 31.1 1.5 44.6 4.3 13.4 2.8 24.8 7.6 34.4 14.1 9.5 6.5 17 15.2 22.3 26 5.2 10.7 7.9 24.1 7.9 40 0 17.2-3.9 31.4-11.7 42.9-7.9 11.5-19.3 20.8-34.8 28.1 21.1 6 36.6 16.7 46.8 31.7 10.4 15.2 15.5 33.4 15.5 54.8 0 17.4-3.3 32.3-10 44.8zM790.8 576H612.4c0 19.4 6.7 38 16.8 48 10.2 9.9 24.8 14.9 43.9 14.9 13.8 0 25.5-3.5 35.5-10.4 9.9-6.9 15.9-14.2 18.1-21.8h59.8c-9.6 29.7-24.2 50.9-44 63.7-19.6 12.8-43.6 19.2-71.5 19.2-19.5 0-37-3.2-52.7-9.3-15.1-5.9-28.7-14.9-39.9-26.5a121.2 121.2 0 01-25.1-41.2c-6.1-16.9-9.1-34.7-8.9-52.6 0-18.5 3.1-35.7 9.1-51.7 11.5-31.1 35.4-56 65.9-68.9 16.3-6.8 33.8-10.2 51.5-10 21 0 39.2 4 55 12.2a111.6 111.6 0 0138.6 32.8c10.1 13.7 17.2 29.3 21.7 46.9 4.3 17.3 5.8 35.5 4.6 54.7zm-122-95.6c-10.8 0-19.9 1.9-26.9 5.6-7 3.7-12.8 8.3-17.2 13.6a48.4 48.4 0 00-9.1 17.4c-1.6 5.3-2.7 10.7-3.1 16.2H723c-1.6-17.3-7.6-30.1-15.6-39.1-8.4-8.9-21.9-13.7-38.6-13.7z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/behance-circle.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M420.3 470.3c8.7-6.3 12.9-16.7 12.9-31 .3-6.8-1.1-13.5-4.1-19.6-2.7-4.9-6.7-9-11.6-11.9a44.8 44.8 0 00-16.6-6c-6.4-1.2-12.9-1.8-19.3-1.7h-70.3v79.7h76.1c13.1.1 24.2-3.1 32.9-9.5zm11.8 72c-9.8-7.5-22.9-11.2-39.2-11.2h-81.8v94h80.2c7.5 0 14.4-.7 21.1-2.1a50.5 50.5 0 0017.8-7.2c5.1-3.3 9.2-7.8 12.3-13.6 3-5.8 4.5-13.2 4.5-22.1 0-17.7-5-30.2-14.9-37.8zM512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm86.5 286.9h138.4v33.7H598.5v-33.7zM512 628.8a89.52 89.52 0 01-27 31c-11.8 8.2-24.9 14.2-38.8 17.7a167.4 167.4 0 01-44.6 5.7H236V342.1h161c16.3 0 31.1 1.5 44.6 4.3 13.4 2.8 24.8 7.6 34.4 14.1 9.5 6.5 17 15.2 22.3 26 5.2 10.7 7.9 24.1 7.9 40 0 17.2-3.9 31.4-11.7 42.9-7.9 11.5-19.3 20.8-34.8 28.1 21.1 6 36.6 16.7 46.8 31.7 10.4 15.2 15.5 33.4 15.5 54.8 0 17.4-3.3 32.3-10 44.8zM790.8 576H612.4c0 19.4 6.7 38 16.8 48 10.2 9.9 24.8 14.9 43.9 14.9 13.8 0 25.5-3.5 35.5-10.4 9.9-6.9 15.9-14.2 18.1-21.8h59.8c-9.6 29.7-24.2 50.9-44 63.7-19.6 12.8-43.6 19.2-71.5 19.2-19.5 0-37-3.2-52.7-9.3-15.1-5.9-28.7-14.9-39.9-26.5a121.2 121.2 0 01-25.1-41.2c-6.1-16.9-9.1-34.7-8.9-52.6 0-18.5 3.1-35.7 9.1-51.7 11.5-31.1 35.4-56 65.9-68.9 16.3-6.8 33.8-10.2 51.5-10 21 0 39.2 4 55 12.2a111.6 111.6 0 0138.6 32.8c10.1 13.7 17.2 29.3 21.7 46.9 4.3 17.3 5.8 35.5 4.6 54.7zm-122-95.6c-10.8 0-19.9 1.9-26.9 5.6-7 3.7-12.8 8.3-17.2 13.6a48.4 48.4 0 00-9.1 17.4c-1.6 5.3-2.7 10.7-3.1 16.2H723c-1.6-17.3-7.6-30.1-15.6-39.1-8.4-8.9-21.9-13.7-38.6-13.7z" /></svg>
|
After Width: | Height: | Size: 1.5 KiB |
7
src/blrec/data/webapp/assets/fill/behance-square.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'behance-square',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M880 112H144c-17.7 0-32 14.3-32 32v736c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V144c0-17.7-14.3-32-32-32zM598.5 350.9h138.4v33.7H598.5v-33.7zM512 628.8a89.52 89.52 0 01-27 31c-11.8 8.2-24.9 14.2-38.8 17.7a167.4 167.4 0 01-44.6 5.7H236V342.1h161c16.3 0 31.1 1.5 44.6 4.3 13.4 2.8 24.8 7.6 34.4 14.1 9.5 6.5 17 15.2 22.3 26 5.2 10.7 7.9 24.1 7.9 40 0 17.2-3.9 31.4-11.7 42.9-7.9 11.5-19.3 20.8-34.8 28.1 21.1 6 36.6 16.7 46.8 31.7 10.4 15.2 15.5 33.4 15.5 54.8 0 17.4-3.3 32.3-10 44.8zM790.8 576H612.4c0 19.4 6.7 38 16.8 48 10.2 9.9 24.8 14.9 43.9 14.9 13.8 0 25.5-3.5 35.5-10.4 9.9-6.9 15.9-14.2 18.1-21.8h59.8c-9.6 29.7-24.2 50.9-44 63.7-19.6 12.8-43.6 19.2-71.5 19.2-19.5 0-37-3.2-52.7-9.3-15.1-5.9-28.7-14.9-39.9-26.5a121.2 121.2 0 01-25.1-41.2c-6.1-16.9-9.1-34.7-8.9-52.6 0-18.5 3.1-35.7 9.1-51.7 11.5-31.1 35.4-56 65.9-68.9 16.3-6.8 33.8-10.2 51.5-10 21 0 39.2 4 55 12.2a111.6 111.6 0 0138.6 32.8c10.1 13.7 17.2 29.3 21.7 46.9 4.3 17.3 5.8 35.5 4.6 54.7zm-122-95.6c-10.8 0-19.9 1.9-26.9 5.6-7 3.7-12.8 8.3-17.2 13.6a48.4 48.4 0 00-9.1 17.4c-1.6 5.3-2.7 10.7-3.1 16.2H723c-1.6-17.3-7.6-30.1-15.6-39.1-8.4-8.9-21.9-13.7-38.6-13.7zm-248.5-10.1c8.7-6.3 12.9-16.7 12.9-31 .3-6.8-1.1-13.5-4.1-19.6-2.7-4.9-6.7-9-11.6-11.9a44.8 44.8 0 00-16.6-6c-6.4-1.2-12.9-1.8-19.3-1.7h-70.3v79.7h76.1c13.1.1 24.2-3.1 32.9-9.5zm11.8 72c-9.8-7.5-22.9-11.2-39.2-11.2h-81.8v94h80.2c7.5 0 14.4-.7 21.1-2.1s12.7-3.8 17.8-7.2c5.1-3.3 9.2-7.8 12.3-13.6 3-5.8 4.5-13.2 4.5-22.1 0-17.7-5-30.2-14.9-37.8z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/behance-square.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M880 112H144c-17.7 0-32 14.3-32 32v736c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V144c0-17.7-14.3-32-32-32zM598.5 350.9h138.4v33.7H598.5v-33.7zM512 628.8a89.52 89.52 0 01-27 31c-11.8 8.2-24.9 14.2-38.8 17.7a167.4 167.4 0 01-44.6 5.7H236V342.1h161c16.3 0 31.1 1.5 44.6 4.3 13.4 2.8 24.8 7.6 34.4 14.1 9.5 6.5 17 15.2 22.3 26 5.2 10.7 7.9 24.1 7.9 40 0 17.2-3.9 31.4-11.7 42.9-7.9 11.5-19.3 20.8-34.8 28.1 21.1 6 36.6 16.7 46.8 31.7 10.4 15.2 15.5 33.4 15.5 54.8 0 17.4-3.3 32.3-10 44.8zM790.8 576H612.4c0 19.4 6.7 38 16.8 48 10.2 9.9 24.8 14.9 43.9 14.9 13.8 0 25.5-3.5 35.5-10.4 9.9-6.9 15.9-14.2 18.1-21.8h59.8c-9.6 29.7-24.2 50.9-44 63.7-19.6 12.8-43.6 19.2-71.5 19.2-19.5 0-37-3.2-52.7-9.3-15.1-5.9-28.7-14.9-39.9-26.5a121.2 121.2 0 01-25.1-41.2c-6.1-16.9-9.1-34.7-8.9-52.6 0-18.5 3.1-35.7 9.1-51.7 11.5-31.1 35.4-56 65.9-68.9 16.3-6.8 33.8-10.2 51.5-10 21 0 39.2 4 55 12.2a111.6 111.6 0 0138.6 32.8c10.1 13.7 17.2 29.3 21.7 46.9 4.3 17.3 5.8 35.5 4.6 54.7zm-122-95.6c-10.8 0-19.9 1.9-26.9 5.6-7 3.7-12.8 8.3-17.2 13.6a48.4 48.4 0 00-9.1 17.4c-1.6 5.3-2.7 10.7-3.1 16.2H723c-1.6-17.3-7.6-30.1-15.6-39.1-8.4-8.9-21.9-13.7-38.6-13.7zm-248.5-10.1c8.7-6.3 12.9-16.7 12.9-31 .3-6.8-1.1-13.5-4.1-19.6-2.7-4.9-6.7-9-11.6-11.9a44.8 44.8 0 00-16.6-6c-6.4-1.2-12.9-1.8-19.3-1.7h-70.3v79.7h76.1c13.1.1 24.2-3.1 32.9-9.5zm11.8 72c-9.8-7.5-22.9-11.2-39.2-11.2h-81.8v94h80.2c7.5 0 14.4-.7 21.1-2.1s12.7-3.8 17.8-7.2c5.1-3.3 9.2-7.8 12.3-13.6 3-5.8 4.5-13.2 4.5-22.1 0-17.7-5-30.2-14.9-37.8z" /></svg>
|
After Width: | Height: | Size: 1.5 KiB |
7
src/blrec/data/webapp/assets/fill/bell.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'bell',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M816 768h-24V428c0-141.1-104.3-257.8-240-277.2V112c0-22.1-17.9-40-40-40s-40 17.9-40 40v38.8C336.3 170.2 232 286.9 232 428v340h-24c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h216c0 61.8 50.2 112 112 112s112-50.2 112-112h216c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM512 888c-26.5 0-48-21.5-48-48h96c0 26.5-21.5 48-48 48z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/bell.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M816 768h-24V428c0-141.1-104.3-257.8-240-277.2V112c0-22.1-17.9-40-40-40s-40 17.9-40 40v38.8C336.3 170.2 232 286.9 232 428v340h-24c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h216c0 61.8 50.2 112 112 112s112-50.2 112-112h216c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM512 888c-26.5 0-48-21.5-48-48h96c0 26.5-21.5 48-48 48z" /></svg>
|
After Width: | Height: | Size: 382 B |
7
src/blrec/data/webapp/assets/fill/book.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'book',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M832 64H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V96c0-17.7-14.3-32-32-32zM668 345.9L621.5 312 572 347.4V124h96v221.9z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/book.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M832 64H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V96c0-17.7-14.3-32-32-32zM668 345.9L621.5 312 572 347.4V124h96v221.9z" /></svg>
|
After Width: | Height: | Size: 218 B |
7
src/blrec/data/webapp/assets/fill/box-plot.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'box-plot',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M952 224h-52c-4.4 0-8 3.6-8 8v248h-92V304c0-4.4-3.6-8-8-8H448v432h344c4.4 0 8-3.6 8-8V548h92v244c0 4.4 3.6 8 8 8h52c4.4 0 8-3.6 8-8V232c0-4.4-3.6-8-8-8zm-728 80v176h-92V232c0-4.4-3.6-8-8-8H72c-4.4 0-8 3.6-8 8v560c0 4.4 3.6 8 8 8h52c4.4 0 8-3.6 8-8V548h92v172c0 4.4 3.6 8 8 8h152V296H232c-4.4 0-8 3.6-8 8z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/box-plot.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M952 224h-52c-4.4 0-8 3.6-8 8v248h-92V304c0-4.4-3.6-8-8-8H448v432h344c4.4 0 8-3.6 8-8V548h92v244c0 4.4 3.6 8 8 8h52c4.4 0 8-3.6 8-8V232c0-4.4-3.6-8-8-8zm-728 80v176h-92V232c0-4.4-3.6-8-8-8H72c-4.4 0-8 3.6-8 8v560c0 4.4 3.6 8 8 8h52c4.4 0 8-3.6 8-8V548h92v172c0 4.4 3.6 8 8 8h152V296H232c-4.4 0-8 3.6-8 8z" /></svg>
|
After Width: | Height: | Size: 370 B |
7
src/blrec/data/webapp/assets/fill/bug.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'bug',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M304 280h416c4.4 0 8-3.6 8-8 0-40-8.8-76.7-25.9-108.1a184.31 184.31 0 00-74-74C596.7 72.8 560 64 520 64h-16c-40 0-76.7 8.8-108.1 25.9a184.31 184.31 0 00-74 74C304.8 195.3 296 232 296 272c0 4.4 3.6 8 8 8z" /><path d="M940 512H792V412c76.8 0 139-62.2 139-139 0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8a63 63 0 01-63 63H232a63 63 0 01-63-63c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8 0 76.8 62.2 139 139 139v100H84c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h148v96c0 6.5.2 13 .7 19.3C164.1 728.6 116 796.7 116 876c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8 0-44.2 23.9-82.9 59.6-103.7a273 273 0 0022.7 49c24.3 41.5 59 76.2 100.5 100.5 28.9 16.9 61 28.8 95.3 34.5 4.4 0 8-3.6 8-8V484c0-4.4 3.6-8 8-8h60c4.4 0 8 3.6 8 8v464.2c0 4.4 3.6 8 8 8 34.3-5.7 66.4-17.6 95.3-34.5a281.38 281.38 0 00123.2-149.5A120.4 120.4 0 01836 876c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8 0-79.3-48.1-147.4-116.7-176.7.4-6.4.7-12.8.7-19.3v-96h148c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/bug.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M304 280h416c4.4 0 8-3.6 8-8 0-40-8.8-76.7-25.9-108.1a184.31 184.31 0 00-74-74C596.7 72.8 560 64 520 64h-16c-40 0-76.7 8.8-108.1 25.9a184.31 184.31 0 00-74 74C304.8 195.3 296 232 296 272c0 4.4 3.6 8 8 8z" /><path d="M940 512H792V412c76.8 0 139-62.2 139-139 0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8a63 63 0 01-63 63H232a63 63 0 01-63-63c0-4.4-3.6-8-8-8h-60c-4.4 0-8 3.6-8 8 0 76.8 62.2 139 139 139v100H84c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h148v96c0 6.5.2 13 .7 19.3C164.1 728.6 116 796.7 116 876c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8 0-44.2 23.9-82.9 59.6-103.7a273 273 0 0022.7 49c24.3 41.5 59 76.2 100.5 100.5 28.9 16.9 61 28.8 95.3 34.5 4.4 0 8-3.6 8-8V484c0-4.4 3.6-8 8-8h60c4.4 0 8 3.6 8 8v464.2c0 4.4 3.6 8 8 8 34.3-5.7 66.4-17.6 95.3-34.5a281.38 281.38 0 00123.2-149.5A120.4 120.4 0 01836 876c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8 0-79.3-48.1-147.4-116.7-176.7.4-6.4.7-12.8.7-19.3v-96h148c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" /></svg>
|
After Width: | Height: | Size: 988 B |
7
src/blrec/data/webapp/assets/fill/build.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'build',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M916 210H376c-17.7 0-32 14.3-32 32v236H108c-17.7 0-32 14.3-32 32v272c0 17.7 14.3 32 32 32h540c17.7 0 32-14.3 32-32V546h236c17.7 0 32-14.3 32-32V242c0-17.7-14.3-32-32-32zM612 746H412V546h200v200zm268-268H680V278h200v200z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/build.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M916 210H376c-17.7 0-32 14.3-32 32v236H108c-17.7 0-32 14.3-32 32v272c0 17.7 14.3 32 32 32h540c17.7 0 32-14.3 32-32V546h236c17.7 0 32-14.3 32-32V242c0-17.7-14.3-32-32-32zM612 746H412V546h200v200zm268-268H680V278h200v200z" /></svg>
|
After Width: | Height: | Size: 285 B |
7
src/blrec/data/webapp/assets/fill/bulb.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'bulb',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M348 676.1C250 619.4 184 513.4 184 392c0-181.1 146.9-328 328-328s328 146.9 328 328c0 121.4-66 227.4-164 284.1V792c0 17.7-14.3 32-32 32H380c-17.7 0-32-14.3-32-32V676.1zM392 888h240c4.4 0 8 3.6 8 8v32c0 17.7-14.3 32-32 32H416c-17.7 0-32-14.3-32-32v-32c0-4.4 3.6-8 8-8z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/bulb.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M348 676.1C250 619.4 184 513.4 184 392c0-181.1 146.9-328 328-328s328 146.9 328 328c0 121.4-66 227.4-164 284.1V792c0 17.7-14.3 32-32 32H380c-17.7 0-32-14.3-32-32V676.1zM392 888h240c4.4 0 8 3.6 8 8v32c0 17.7-14.3 32-32 32H416c-17.7 0-32-14.3-32-32v-32c0-4.4 3.6-8 8-8z" /></svg>
|
After Width: | Height: | Size: 332 B |
7
src/blrec/data/webapp/assets/fill/calculator.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'calculator',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M880 112H144c-17.7 0-32 14.3-32 32v736c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V144c0-17.7-14.3-32-32-32zM440.2 765h-50.8c-2.2 0-4.5-1.1-5.9-2.9L348 718.6l-35.5 43.5a7.38 7.38 0 01-5.9 2.9h-50.8c-6.6 0-10.2-7.9-5.8-13.1l62.7-76.8-61.2-74.9c-4.3-5.2-.7-13.1 5.9-13.1h50.9c2.2 0 4.5 1.1 5.9 2.9l34 41.6 34-41.6c1.5-1.9 3.6-2.9 5.9-2.9h50.8c6.6 0 10.2 7.9 5.9 13.1L383.5 675l62.7 76.8c4.2 5.3.6 13.2-6 13.2zm7.8-382c0 2.2-1.4 4-3.2 4H376v68.7c0 1.9-1.8 3.3-4 3.3h-48c-2.2 0-4-1.4-4-3.2V387h-68.8c-1.8 0-3.2-1.8-3.2-4v-48c0-2.2 1.4-4 3.2-4H320v-68.8c0-1.8 1.8-3.2 4-3.2h48c2.2 0 4 1.4 4 3.2V331h68.7c1.9 0 3.3 1.8 3.3 4v48zm328 369c0 2.2-1.4 4-3.2 4H579.2c-1.8 0-3.2-1.8-3.2-4v-48c0-2.2 1.4-4 3.2-4h193.5c1.9 0 3.3 1.8 3.3 4v48zm0-104c0 2.2-1.4 4-3.2 4H579.2c-1.8 0-3.2-1.8-3.2-4v-48c0-2.2 1.4-4 3.2-4h193.5c1.9 0 3.3 1.8 3.3 4v48zm0-265c0 2.2-1.4 4-3.2 4H579.2c-1.8 0-3.2-1.8-3.2-4v-48c0-2.2 1.4-4 3.2-4h193.5c1.9 0 3.3 1.8 3.3 4v48z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/calculator.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M880 112H144c-17.7 0-32 14.3-32 32v736c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V144c0-17.7-14.3-32-32-32zM440.2 765h-50.8c-2.2 0-4.5-1.1-5.9-2.9L348 718.6l-35.5 43.5a7.38 7.38 0 01-5.9 2.9h-50.8c-6.6 0-10.2-7.9-5.8-13.1l62.7-76.8-61.2-74.9c-4.3-5.2-.7-13.1 5.9-13.1h50.9c2.2 0 4.5 1.1 5.9 2.9l34 41.6 34-41.6c1.5-1.9 3.6-2.9 5.9-2.9h50.8c6.6 0 10.2 7.9 5.9 13.1L383.5 675l62.7 76.8c4.2 5.3.6 13.2-6 13.2zm7.8-382c0 2.2-1.4 4-3.2 4H376v68.7c0 1.9-1.8 3.3-4 3.3h-48c-2.2 0-4-1.4-4-3.2V387h-68.8c-1.8 0-3.2-1.8-3.2-4v-48c0-2.2 1.4-4 3.2-4H320v-68.8c0-1.8 1.8-3.2 4-3.2h48c2.2 0 4 1.4 4 3.2V331h68.7c1.9 0 3.3 1.8 3.3 4v48zm328 369c0 2.2-1.4 4-3.2 4H579.2c-1.8 0-3.2-1.8-3.2-4v-48c0-2.2 1.4-4 3.2-4h193.5c1.9 0 3.3 1.8 3.3 4v48zm0-104c0 2.2-1.4 4-3.2 4H579.2c-1.8 0-3.2-1.8-3.2-4v-48c0-2.2 1.4-4 3.2-4h193.5c1.9 0 3.3 1.8 3.3 4v48zm0-265c0 2.2-1.4 4-3.2 4H579.2c-1.8 0-3.2-1.8-3.2-4v-48c0-2.2 1.4-4 3.2-4h193.5c1.9 0 3.3 1.8 3.3 4v48z" /></svg>
|
After Width: | Height: | Size: 1001 B |
7
src/blrec/data/webapp/assets/fill/calendar.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'calendar',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M112 880c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V460H112v420zm768-696H712v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H384v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H144c-17.7 0-32 14.3-32 32v176h800V216c0-17.7-14.3-32-32-32z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/calendar.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M112 880c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V460H112v420zm768-696H712v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H384v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H144c-17.7 0-32 14.3-32 32v176h800V216c0-17.7-14.3-32-32-32z" /></svg>
|
After Width: | Height: | Size: 297 B |
7
src/blrec/data/webapp/assets/fill/camera.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'camera',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M864 260H728l-32.4-90.8a32.07 32.07 0 00-30.2-21.2H358.6c-13.5 0-25.6 8.5-30.1 21.2L296 260H160c-44.2 0-80 35.8-80 80v456c0 44.2 35.8 80 80 80h704c44.2 0 80-35.8 80-80V340c0-44.2-35.8-80-80-80zM512 716c-88.4 0-160-71.6-160-160s71.6-160 160-160 160 71.6 160 160-71.6 160-160 160zm-96-160a96 96 0 10192 0 96 96 0 10-192 0z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/camera.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M864 260H728l-32.4-90.8a32.07 32.07 0 00-30.2-21.2H358.6c-13.5 0-25.6 8.5-30.1 21.2L296 260H160c-44.2 0-80 35.8-80 80v456c0 44.2 35.8 80 80 80h704c44.2 0 80-35.8 80-80V340c0-44.2-35.8-80-80-80zM512 716c-88.4 0-160-71.6-160-160s71.6-160 160-160 160 71.6 160 160-71.6 160-160 160zm-96-160a96 96 0 10192 0 96 96 0 10-192 0z" /></svg>
|
After Width: | Height: | Size: 386 B |
7
src/blrec/data/webapp/assets/fill/car.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'car',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="64 64 896 896" focusable="false"><path d="M959 413.4L935.3 372a8 8 0 00-10.9-2.9l-50.7 29.6-78.3-216.2a63.9 63.9 0 00-60.9-44.4H301.2c-34.7 0-65.5 22.4-76.2 55.5l-74.6 205.2-50.8-29.6a8 8 0 00-10.9 2.9L65 413.4c-2.2 3.8-.9 8.6 2.9 10.8l60.4 35.2-14.5 40c-1.2 3.2-1.8 6.6-1.8 10v348.2c0 15.7 11.8 28.4 26.3 28.4h67.6c12.3 0 23-9.3 25.6-22.3l7.7-37.7h545.6l7.7 37.7c2.7 13 13.3 22.3 25.6 22.3h67.6c14.5 0 26.3-12.7 26.3-28.4V509.4c0-3.4-.6-6.8-1.8-10l-14.5-40 60.3-35.2a8 8 0 003-10.8zM264 621c-22.1 0-40-17.9-40-40s17.9-40 40-40 40 17.9 40 40-17.9 40-40 40zm388 75c0 4.4-3.6 8-8 8H380c-4.4 0-8-3.6-8-8v-84c0-4.4 3.6-8 8-8h40c4.4 0 8 3.6 8 8v36h168v-36c0-4.4 3.6-8 8-8h40c4.4 0 8 3.6 8 8v84zm108-75c-22.1 0-40-17.9-40-40s17.9-40 40-40 40 17.9 40 40-17.9 40-40 40zM220 418l72.7-199.9.5-1.3.4-1.3c1.1-3.3 4.1-5.5 7.6-5.5h427.6l75.4 208H220z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/car.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="64 64 896 896" focusable="false"><path d="M959 413.4L935.3 372a8 8 0 00-10.9-2.9l-50.7 29.6-78.3-216.2a63.9 63.9 0 00-60.9-44.4H301.2c-34.7 0-65.5 22.4-76.2 55.5l-74.6 205.2-50.8-29.6a8 8 0 00-10.9 2.9L65 413.4c-2.2 3.8-.9 8.6 2.9 10.8l60.4 35.2-14.5 40c-1.2 3.2-1.8 6.6-1.8 10v348.2c0 15.7 11.8 28.4 26.3 28.4h67.6c12.3 0 23-9.3 25.6-22.3l7.7-37.7h545.6l7.7 37.7c2.7 13 13.3 22.3 25.6 22.3h67.6c14.5 0 26.3-12.7 26.3-28.4V509.4c0-3.4-.6-6.8-1.8-10l-14.5-40 60.3-35.2a8 8 0 003-10.8zM264 621c-22.1 0-40-17.9-40-40s17.9-40 40-40 40 17.9 40 40-17.9 40-40 40zm388 75c0 4.4-3.6 8-8 8H380c-4.4 0-8-3.6-8-8v-84c0-4.4 3.6-8 8-8h40c4.4 0 8 3.6 8 8v36h168v-36c0-4.4 3.6-8 8-8h40c4.4 0 8 3.6 8 8v84zm108-75c-22.1 0-40-17.9-40-40s17.9-40 40-40 40 17.9 40 40-17.9 40-40 40zM220 418l72.7-199.9.5-1.3.4-1.3c1.1-3.3 4.1-5.5 7.6-5.5h427.6l75.4 208H220z" /></svg>
|
After Width: | Height: | Size: 860 B |
7
src/blrec/data/webapp/assets/fill/caret-down.js
Normal file
@ -0,0 +1,7 @@
|
||||
(function() {
|
||||
__ant_icon_load({
|
||||
name: 'caret-down',
|
||||
theme: 'fill',
|
||||
icon: '<svg viewBox="0 0 1024 1024" focusable="false"><path d="M840.4 300H183.6c-19.7 0-30.7 20.8-18.5 35l328.4 380.8c9.4 10.9 27.5 10.9 37 0L858.9 335c12.2-14.2 1.2-35-18.5-35z" /></svg>'
|
||||
});
|
||||
})()
|
1
src/blrec/data/webapp/assets/fill/caret-down.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 1024 1024" focusable="false"><path d="M840.4 300H183.6c-19.7 0-30.7 20.8-18.5 35l328.4 380.8c9.4 10.9 27.5 10.9 37 0L858.9 335c12.2-14.2 1.2-35-18.5-35z" /></svg>
|
After Width: | Height: | Size: 180 B |