release: v1.1.0

This commit is contained in:
acgnhiki 2021-10-09 17:19:23 +08:00
parent 6b61738d09
commit 672c3f094e
44 changed files with 965 additions and 335 deletions

View File

@ -1,5 +1,12 @@
# 更新日志
## 1.1.0
- 支持记录送物、上舰、醒目留言到弹幕文件
- 支持保存原始弹幕为 JSON lines 文件
- 弹幕协议更新了,更新弹幕客户端支持新的弹幕协议。
- 对前端界面样式做了些微调整
## 1.0.5
- 修复路径模板设置的模板变量显示不完整

76
FAQ.md Normal file
View File

@ -0,0 +1,76 @@
# 常见问题
## 如何终止程序?
`ctrl + c`
## 设置修改后什么时候生效?
设置修改后基本上是即时生效的,但是对于正在录制的任务有些例外。
对保存位置或路径模板的修改,不影响当前正在录制的文件,要下一次创建文件才会生效。
对画质的修改,要下一次获取直播流 `url` 才会生效。如果想立即生效,停止录制再重新开始录制就可以。
## 什么是流拼接和无缝拼接?
流拼接就是在录制的过程中出现网络中断时从中断的位置接续上去,以避免录播文件片段化。
而无缝拼接就是把中断处前后重复的数据丢掉然后再接续上,就好像没中断过一样,不会丢失数据。
**注意:只有网络中断后在短时间内重新连接上,中断处前后有重复数据才有可能进行无缝拼接。**
## 怎么知道是否出现了流中断?
如果流出现了中断,不但日志里有记录,程序还会把流中断的详细信息写入到视频文件的元数据里。
可以用 `ffprob` 查看视频文件元数据的 `Comment` 字段。
有些播放器也可以查看。`VLC` 可以按 `ctrl + i` 后查看注释;`PotPlayer` 可以按两次 tab 键查看评论mp4 没问题,但是 flv 会因为内容过长而不显示~)。
mp4 还在非无缝拼接的位置添加了章节标记,支持章节标记的播放器应该可以看到。
## 如何避免流中断、漏录?
cpu 使用率过高、网络带宽不足或不稳定、硬盘读写慢都会导致流中断,如果不能及时恢复就无法进行无缝拼接进而导致漏录了。所以,只要运行程序的机器配置足够高且网络带宽充足稳定就可以有效避免录制端导致的流中断、漏录。
主播网络或 B 站服务器问题导致的流中断、漏录是无法避免的~
## 为什么录播文件的时长小于直播持续时间?
- 开播后推流有延迟
- 录制过程出现流中断并且未能无缝拼接,漏录了~
- 主播网络不稳定,流中出现了时间戳跳变、反跳,经过修正后时长可能会变短。
## 为什么录制的画质和所设置的画质不符合?
所设置的画质在开播后不一定存在,如果不存在就会以原画代替。
## 为什么按文件大小或时长分割的文件比设置的值小或短?
分割位置必须在关键帧处才不会丢帧。为了确保文件不会超过指定的限制,会在关键帧将超过所设置的值的前一个关键帧处进行分割,所以文件的大小或时长要比所设置的值小一些。
## 为什么没有设置分割文件还是出现了多个录播文件?
为了避免录播文件出现花屏等问题,在直播流参数改变(主播修改分辨率、码率等)时就会自动分割文件。
有时则是主播网络很不稳定造成多次下播上播导致的。
## 为什么设置了转换为 `mp4` 格式,但却没有进行转换?
请确保正确安装了 `ffmpeg`,可以在终端里正常使用 `ffmpeg`
## 怎样才算是旧录播文件?
创建时间超过 24 小时才会被当成旧录播文件在空间不足时被删除。
## 空间不足时是怎样删除旧录播文件的?
删除文件是按创建时间的先后进行的,最早创建的最先被删除,直到可用空间不少于所设置的阈值为止。
## 支持录制付费直播吗?
没试过~ 可以尝试在网络请求设置里填写已付费账号登录后的 Cookie
## 为什么要重复造轮子?
因为现有工具大多都是直接下载流或调用 ffmpeg 进行录制,不能很好地解决漏录、数据损坏、录播文件片段化等录播问题。尤其录制的直播很不稳定,结果很不尽如人意。

136
README.md
View File

@ -33,11 +33,36 @@
## 安装
pip install blrec
- 通过 pip 安装
用到的一些库需要 C 编译器Windows 没 C 编译器会安装出错,使用以下方法安装已编译好的库。
`pip install blrec`
pip install -r windows-requirements.txt
用到的一些库需要 C 编译器Windows 没 C 编译器会安装出错,
使用以下方式先安装已编译好的库然后再按照上面的安装。
`pip install -r windows-requirements.txt`
- 免安装绿色版
Windows 64 位系统用户也可以用打包好的免安装绿色版,下载后解压运行 `run.bat` 即可。
下载
- Releases: https://github.com/acgnhiki/blrec/releases
- 网盘: https://gooyie.lanzoui.com/b01om2zte 密码: 2233
## 更新
- 通过 pip 安装的用以下方式更新
`pip install blrec --upgrade`
- 免安装绿色版
- 下载并解压新版本
- 确保旧版本已经关闭退出以避免之后出现端口冲突
- 把旧版本的设置文件 `settings.toml` 复制并覆盖新版本的设置文件
- 运行新版本的 `run.bat`
## 使用方法
@ -55,15 +80,23 @@
### 绑定主机和端口
`blrec --host 0.0.0.0 --port 8000`
默认为本地运行,主机和端口绑定为: `localhost:2233`
默认主机 `127.0.0.1`,默认端口 `2233`.
需要外网访问,把主机绑定到 `0.0.0.0`,端口绑定则按照自己的情况修改。
例如:`blrec --host 0.0.0.0 --port 8000`
### 安全保障
`blrec --key-file path/to/key-file --cert-file path/to/cert-file --api-key ********`
指定 `SSL` 证书使用 **https** 协议并指定 `api key` 可防止被恶意访问和泄漏设置里的敏感信息
可以使用 `api key` 防止被恶意访问和泄漏设置里的敏感信息
例如:`blrec --key-file path/to/key-file --cert-file path/to/cert-file --api-key bili2233`
如果指定了 api key浏览器第一次访问会弹对话框要求输入 api key。
输入的 api key 会被保存在浏览器的 `local storage` 以避免每次都得输入
如果在不信任的环境下,请使用浏览器的隐式模式访问。
## 作为 ASGI 应用运行
@ -105,95 +138,14 @@
---
## 常见问题
[FAQ](FAQ.md)
## 更新日志
[CHANGELOG](CHANGELOG.md)
---
## 常见问题
### 如何终止程序?
`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 进行录制,不能很好地解决漏录、数据损坏、录播文件片段化等录播问题。尤其录制的直播很不稳定,结果很不尽如人意。
---
## 赞助 & 支持
如果觉得这个工具好用,对你有所帮助,可以投喂支持亿下哦~

View File

@ -52,6 +52,7 @@ install_requires =
psutil >= 5.8.0, < 5.9.0
rx >= 3.1.1, < 3.2.0
bitarray >= 2.2.5, < 2.3.0
brotli >= 1.0.9, < 1.1.0
uvicorn[standard] >=0.12.0, < 0.15.0
[options.extras_require]

View File

@ -1,4 +1,4 @@
__prog__ = 'blrec'
__version__ = '1.0.5'
__version__ = '1.1.0'
__github__ = 'https://github.com/acgnhiki/blrec'

View File

@ -20,6 +20,8 @@ class WebApi:
GET_USER_INFO_URL: Final[str] = BASE_API_URL + '/x/space/acc/info'
GET_DANMU_INFO_URL: Final[str] = BASE_LIVE_API_URL + \
'/xlive/web-room/v1/index/getDanmuInfo'
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 + \
@ -99,3 +101,10 @@ class WebApi:
}
r = await self._get(self.GET_USER_INFO_URL, params=params)
return r['data']
async def get_danmu_info(self, room_id: int) -> ResponseData:
params = {
'id': room_id,
}
r = await self._get(self.GET_DANMU_INFO_URL, params=params)
return r['data']

View File

@ -1,7 +1,5 @@
#!/usr/bin/env python3
import json
import struct
import zlib
import asyncio
import logging
from enum import IntEnum, Enum
@ -10,12 +8,14 @@ from typing import Any, Dict, Final, Tuple, List, Union, cast, Optional
import aiohttp
from aiohttp import ClientSession
import brotli
from tenacity import (
retry,
wait_exponential,
retry_if_exception_type,
)
from .api import WebApi
from .typing import Danmaku
from ..event.event_emitter import EventListener, EventEmitter
from ..exception import exception_callback
@ -47,7 +47,6 @@ class DanmakuListener(EventListener):
class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
_URL: Final[str] = 'wss://broadcastlv.chat.bilibili.com:443/sub'
_HEARTBEAT_INTERVAL: Final[int] = 30
def __init__(
@ -60,11 +59,14 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
super().__init__()
self.session = session
self._room_id = room_id
self._api = WebApi(session)
self._host_index: int = 0
self._retry_delay: int = 0
self._MAX_RETRIES: Final[int] = max_retries
async def _do_start(self) -> None:
await self._update_danmu_info()
await self._connect()
await self._create_message_loop()
logger.debug('Started danmaku client')
@ -94,23 +96,31 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
await self._connect_websocket()
await self._send_auth()
reply = await self._recieve_auth_reply()
self._handle_auth_reply(reply)
await 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)
url = 'wss://{}:{}/sub'.format(
self._danmu_info['host_list'][self._host_index]['host'],
self._danmu_info['host_list'][self._host_index]['wss_port'],
)
try:
self._ws = await self.session.ws_connect(url, timeout=5)
except BaseException:
host_count = len(self._danmu_info['host_list'])
self._host_index = (self._host_index + 1) % host_count
raise
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,
'protover': WS.BODY_PROTOCOL_VERSION_BROTLI,
'platform': 'web',
'clientver': '1.1.031',
'type': 2
'type': 2,
'key': self._danmu_info['token'],
})
data = Frame.encode(WS.OP_USER_AUTHENTICATION, auth_msg)
await self._ws.send_bytes(data)
@ -123,7 +133,7 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
logger.debug('Recieved reply')
return msg
def _handle_auth_reply(self, reply: aiohttp.WSMessage) -> None:
async 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)
@ -133,11 +143,16 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
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}')
logger.debug('Token expired, will try to reconnect.')
await self._update_danmu_info()
raise ValueError(f'Token expired: {code}')
else:
raise ValueError(f'Unexpected code: {code}')
async def _update_danmu_info(self) -> None:
self._danmu_info = await self._api.get_danmu_info(self._room_id)
logger.debug('Danmu info updated')
async def _disconnect(self) -> None:
await self._cancel_heartbeat_task()
await self._close_websocket()
@ -267,8 +282,8 @@ class Frame:
body = data[hlen:]
if op == WS.OP_MESSAGE:
if ver == WS.BODY_PROTOCOL_VERSION_DEFLATE:
data = zlib.decompress(body)
if ver == WS.BODY_PROTOCOL_VERSION_BROTLI:
data = brotli.decompress(body)
msg_list = []
offset = 0
@ -293,24 +308,24 @@ class Frame:
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
OP_CONNECT_SUCCESS = 8
PACKAGE_HEADER_TOTAL_LENGTH = 16
PACKAGE_OFFSET = 0
SEQUENCE_OFFSET = 12
HEADER_OFFSET = 4
VERSION_OFFSET = 6
OPERATION_OFFSET = 8
SEQUENCE_OFFSET = 12
BODY_PROTOCOL_VERSION_NORMAL = 0
BODY_PROTOCOL_VERSION_BROTLI = 3
HEADER_DEFAULT_VERSION = 1
HEADER_DEFAULT_OPERATION = 1
HEADER_DEFAULT_SEQUENCE = 1
AUTH_OK = 0
AUTH_TOKEN_ERROR = -101
class DanmakuCommand(Enum):
@ -395,149 +410,5 @@ class DanmakuCommand(Enum):
WIN_ACTIVITY = 'WIN_ACTIVITY'
WIN_ACTIVITY_USER = 'WIN_ACTIVITY_USER'
WISH_BOTTLE = 'WISH_BOTTLE'
GUARD_BUY = 'GUARD_BUY'
# ...
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

View File

@ -4,6 +4,8 @@ import logging
from contextlib import suppress
from typing import Iterator, List
from blrec.core.models import GiftSendMsg, GuardBuyMsg, SuperChatMsg
from .. import __version__, __prog__, __github__
from .danmaku_receiver import DanmakuReceiver, DanmuMsg
@ -12,7 +14,9 @@ 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.models import (
Metadata, Danmu, GiftSendRecord, GuardBuyRecord, SuperChatRecord
)
from ..danmaku.io import DanmakuWriter
from ..utils.mixins import SwitchableMixin
from ..logging.room_id import aio_task_with_room_id
@ -32,6 +36,9 @@ class DanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
danmaku_receiver: DanmakuReceiver,
*,
danmu_uname: bool = False,
record_gift_send: bool = False,
record_guard_buy: bool = False,
record_super_chat: bool = False,
) -> None:
super().__init__()
@ -40,6 +47,9 @@ class DanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
self._receiver = danmaku_receiver
self.danmu_uname = danmu_uname
self.record_gift_send = record_gift_send
self.record_guard_buy = record_guard_buy
self.record_super_chat = record_super_chat
self._files: List[str] = []
self._calculator = StatisticsCalculator(interval=60)
@ -115,8 +125,29 @@ class DanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
while True:
msg = await self._receiver.get_message()
await writer.write_danmu(self._make_danmu(msg))
self._calculator.submit(1)
if isinstance(msg, DanmuMsg):
await writer.write_danmu(self._make_danmu(msg))
self._calculator.submit(1)
elif isinstance(msg, GiftSendMsg):
if not self.record_gift_send:
continue
await writer.write_gift_send_record(
self._make_gift_send_record(msg)
)
elif isinstance(msg, GuardBuyMsg):
if not self.record_guard_buy:
continue
await writer.write_guard_buy_record(
self._make_guard_buy_record(msg)
)
elif isinstance(msg, SuperChatMsg):
if not self.record_super_chat:
continue
await writer.write_super_chat_record(
self._make_super_chat_record(msg)
)
else:
logger.warning('Unsupported message type:', repr(msg))
finally:
logger.info(f"Danmaku file completed: '{self._path}'")
logger.debug('Stopped dumping danmaku')
@ -135,8 +166,6 @@ class DanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
)
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:
@ -144,13 +173,50 @@ class DanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
text = html.escape(text)
return Danmu(
stime=stime,
stime=self._calc_stime(msg.date),
mode=msg.mode,
size=msg.size,
color=msg.color,
date=msg.date,
pool=msg.pool,
uid_hash=msg.uid_hash,
uid=msg.uid,
uname=msg.uname,
dmid=msg.dmid,
text=text,
)
def _make_gift_send_record(self, msg: GiftSendMsg) -> GiftSendRecord:
return GiftSendRecord(
ts=self._calc_stime(msg.timestamp * 1000),
uid=msg.uid,
user=msg.uname,
giftname=msg.gift_name,
giftcount=msg.count,
cointype=msg.coin_type,
price=msg.price,
)
def _make_guard_buy_record(self, msg: GuardBuyMsg) -> GuardBuyRecord:
return GuardBuyRecord(
ts=self._calc_stime(msg.timestamp * 1000),
uid=msg.uid,
user=msg.uname,
giftname=msg.gift_name,
count=msg.count,
price=msg.price,
level=msg.guard_level,
)
def _make_super_chat_record(self, msg: SuperChatMsg) -> SuperChatRecord:
return SuperChatRecord(
ts=self._calc_stime(msg.timestamp * 1000),
uid=msg.uid,
user=msg.uname,
price=msg.price,
time=msg.time,
message=msg.message,
)
def _calc_stime(self, timestamp: int) -> float:
return max((timestamp - self._record_start_time * 1000), 0) / 1000

View File

@ -3,7 +3,8 @@ from asyncio import Queue, QueueFull
from typing import Final
from .models import DanmuMsg
from .models import DanmuMsg, GiftSendMsg, GuardBuyMsg, SuperChatMsg
from .typing import DanmakuMsg
from ..bili.danmaku_client import (
DanmakuClient, DanmakuListener, DanmakuCommand
)
@ -23,7 +24,7 @@ class DanmakuReceiver(DanmakuListener, StoppableMixin):
def __init__(self, danmaku_client: DanmakuClient) -> None:
super().__init__()
self._danmaku_client = danmaku_client
self._queue: Queue[DanmuMsg] = Queue(maxsize=self._MAX_QUEUE_SIZE)
self._queue: Queue[DanmakuMsg] = Queue(maxsize=self._MAX_QUEUE_SIZE)
def _do_start(self) -> None:
self._danmaku_client.add_listener(self)
@ -34,19 +35,32 @@ class DanmakuReceiver(DanmakuListener, StoppableMixin):
self._clear_queue()
logger.debug('Stopped danmaku receiver')
async def get_message(self) -> DanmuMsg:
async def get_message(self) -> DanmakuMsg:
return await self._queue.get()
async def on_danmaku_received(self, danmu: Danmaku) -> None:
if danmu['cmd'] != DanmakuCommand.DANMU_MSG.value:
cmd = danmu['cmd']
msg: DanmakuMsg
if cmd == DanmakuCommand.DANMU_MSG.value:
msg = DanmuMsg.from_danmu(danmu)
elif cmd == DanmakuCommand.SEND_GIFT.value:
msg = GiftSendMsg.from_danmu(danmu)
elif cmd == DanmakuCommand.SPECIAL_GIFT.value: # TODO
logger.warning('SPECIAL_GIFT has unsupported yet:', repr(danmu))
return
elif cmd == DanmakuCommand.GUARD_BUY.value:
msg = GuardBuyMsg.from_danmu(danmu)
elif cmd == DanmakuCommand.SUPER_CHAT_MESSAGE.value:
msg = SuperChatMsg.from_danmu(danmu)
else:
return
danmu_msg = DanmuMsg.from_cmd(danmu)
try:
self._queue.put_nowait(danmu_msg)
self._queue.put_nowait(msg)
except QueueFull:
self._queue.get_nowait() # discard the first item
self._queue.put_nowait(danmu_msg)
self._queue.put_nowait(msg)
def _clear_queue(self) -> None:
self._queue = Queue(maxsize=self._MAX_QUEUE_SIZE)

View File

@ -1,4 +1,5 @@
import logging
from typing import Literal
import attr
@ -16,12 +17,13 @@ class DanmuMsg:
date: int # a timestamp in miliseconds
dmid: int
pool: int
uid: str # midHash
text: str
uid_hash: str
uid: int
uname: str # sender name
text: str
@staticmethod
def from_cmd(danmu: Danmaku) -> 'DanmuMsg':
def from_danmu(danmu: Danmaku) -> 'DanmuMsg':
info = danmu['info']
return DanmuMsg(
mode=int(info[0][1]),
@ -30,7 +32,82 @@ class DanmuMsg:
date=int(info[0][4]),
dmid=int(info[0][5]),
pool=int(info[0][6]),
uid=info[0][7],
text=info[1],
uid_hash=info[0][7],
uid=int(info[2][0]),
uname=info[2][1],
text=info[1],
)
@attr.s(auto_attribs=True, frozen=True, slots=True)
class GiftSendMsg:
gift_name: str
count: int
coin_type: Literal['sliver', 'gold']
price: int
uid: int
uname: str
timestamp: int # timestamp in seconds
@staticmethod
def from_danmu(danmu: Danmaku) -> 'GiftSendMsg':
data = danmu['data']
return GiftSendMsg(
gift_name=data['giftName'],
count=int(data['num']),
coin_type=data['coin_type'],
price=int(data['price']),
uid=int(data['uid']),
uname=data['uname'],
timestamp=int(data['timestamp']),
)
@attr.s(auto_attribs=True, frozen=True, slots=True)
class GuardBuyMsg:
gift_name: str # TODO verify
count: int
price: int
uid: int
uname: str
guard_level: int # 1 总督, 2 提督, 3 舰长
timestamp: int # timestamp in seconds
@staticmethod
def from_danmu(danmu: Danmaku) -> 'GuardBuyMsg':
data = danmu['data']
return GuardBuyMsg(
gift_name=data['gift_name'],
count=int(data['num']),
price=int(data['price']),
uid=int(data['uid']),
uname=data['username'],
guard_level=int(data['guard_level']),
timestamp=int(data['start_time']),
)
@attr.s(auto_attribs=True, frozen=True, slots=True)
class SuperChatMsg:
gift_name: str
count: int
price: int
time: int # duration in seconds
message: str
uid: int
uname: str
timestamp: int # timestamp in seconds
@staticmethod
def from_danmu(danmu: Danmaku) -> 'SuperChatMsg':
data = danmu['data']
return SuperChatMsg(
gift_name=data['gift']['gift_name'],
count=int(data['gift']['num']),
price=int(data['price']),
time=int(data['time']),
message=data['message'],
uid=int(data['uid']),
uname=data['user_info']['uname'],
timestamp=int(data['ts']),
)

View File

@ -0,0 +1,77 @@
import json
import asyncio
import logging
from contextlib import suppress
import aiofiles
from .raw_danmaku_receiver import RawDanmakuReceiver
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
from ..exception import exception_callback
from ..path import raw_danmaku_path
from ..utils.mixins import SwitchableMixin
from ..logging.room_id import aio_task_with_room_id
__all__ = 'RawDanmakuDumper',
logger = logging.getLogger(__name__)
class RawDanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
def __init__(
self,
stream_recorder: StreamRecorder,
danmaku_receiver: RawDanmakuReceiver,
) -> None:
super().__init__()
self._stream_recorder = stream_recorder
self._receiver = danmaku_receiver
def _do_enable(self) -> None:
self._stream_recorder.add_listener(self)
logger.debug('Enabled raw danmaku dumper')
def _do_disable(self) -> None:
self._stream_recorder.remove_listener(self)
logger.debug('Disabled raw danmaku dumper')
async def on_video_file_created(
self, video_path: str, record_start_time: int
) -> None:
self._path = raw_danmaku_path(video_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 raw danmaku')
try:
async with aiofiles.open(self._path, 'wt', encoding='utf8') as f:
logger.info(f"Raw danmaku file created: '{self._path}'")
while True:
danmu = await self._receiver.get_raw_danmaku()
json_string = json.dumps(danmu, ensure_ascii=False)
await f.write(json_string + '\n')
finally:
logger.info(f"Raw danmaku file completed: '{self._path}'")
logger.debug('Stopped dumping raw danmaku')

View File

@ -0,0 +1,45 @@
import logging
from asyncio import Queue, QueueFull
from typing import Final
from ..bili.danmaku_client import DanmakuClient, DanmakuListener
from ..bili.typing import Danmaku
from ..utils.mixins import StoppableMixin
__all__ = 'RawDanmakuReceiver',
logger = logging.getLogger(__name__)
class RawDanmakuReceiver(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[Danmaku] = Queue(maxsize=self._MAX_QUEUE_SIZE)
def _do_start(self) -> None:
self._danmaku_client.add_listener(self)
logger.debug('Started raw danmaku receiver')
def _do_stop(self) -> None:
self._danmaku_client.remove_listener(self)
self._clear_queue()
logger.debug('Stopped raw danmaku receiver')
async def get_raw_danmaku(self) -> Danmaku:
return await self._queue.get()
async def on_danmaku_received(self, danmu: Danmaku) -> None:
try:
self._queue.put_nowait(danmu)
except QueueFull:
self._queue.get_nowait() # discard the first item
self._queue.put_nowait(danmu)
def _clear_queue(self) -> None:
self._queue = Queue(maxsize=self._MAX_QUEUE_SIZE)

View File

@ -9,6 +9,8 @@ import humanize
from .danmaku_receiver import DanmakuReceiver
from .danmaku_dumper import DanmakuDumper
from .raw_danmaku_receiver import RawDanmakuReceiver
from .raw_danmaku_dumper import RawDanmakuDumper
from .stream_recorder import StreamRecorder
from ..event.event_emitter import EventListener, EventEmitter
from ..bili.live import Live
@ -50,6 +52,10 @@ class Recorder(
buffer_size: Optional[int] = None,
read_timeout: Optional[int] = None,
danmu_uname: bool = False,
record_gift_send: bool = False,
record_guard_buy: bool = False,
record_super_chat: bool = False,
save_raw_danmaku: bool = False,
filesize_limit: int = 0,
duration_limit: int = 0,
) -> None:
@ -58,6 +64,7 @@ class Recorder(
self._live = live
self._danmaku_client = danmaku_client
self._live_monitor = live_monitor
self.save_raw_danmaku = save_raw_danmaku
self._recording: bool = False
@ -70,12 +77,21 @@ class Recorder(
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,
record_gift_send=record_gift_send,
record_guard_buy=record_guard_buy,
record_super_chat=record_super_chat,
)
self._raw_danmaku_receiver = RawDanmakuReceiver(danmaku_client)
self._raw_danmaku_dumper = RawDanmakuDumper(
self._stream_recorder,
self._raw_danmaku_receiver,
)
@property
@ -118,6 +134,30 @@ class Recorder(
def danmu_uname(self, value: bool) -> None:
self._danmaku_dumper.danmu_uname = value
@property
def record_gift_send(self) -> bool:
return self._danmaku_dumper.record_gift_send
@record_gift_send.setter
def record_gift_send(self, value: bool) -> None:
self._danmaku_dumper.record_gift_send = value
@property
def record_guard_buy(self) -> bool:
return self._danmaku_dumper.record_guard_buy
@record_guard_buy.setter
def record_guard_buy(self, value: bool) -> None:
self._danmaku_dumper.record_guard_buy = value
@property
def record_super_chat(self) -> bool:
return self._danmaku_dumper.record_super_chat
@record_super_chat.setter
def record_super_chat(self, value: bool) -> None:
self._danmaku_dumper.record_super_chat = value
@property
def elapsed(self) -> float:
return self._stream_recorder.elapsed
@ -217,8 +257,12 @@ class Recorder(
return
self._recording = True
if self.save_raw_danmaku:
self._raw_danmaku_dumper.enable()
self._raw_danmaku_receiver.start()
self._danmaku_dumper.enable()
self._danmaku_receiver.start()
await self._prepare()
if stream_available:
await self._stream_recorder.start()
@ -232,6 +276,9 @@ class Recorder(
self._recording = False
await self._stream_recorder.stop()
if self.save_raw_danmaku:
self._raw_danmaku_dumper.disable()
self._raw_danmaku_receiver.stop()
self._danmaku_dumper.disable()
self._danmaku_receiver.stop()

12
src/blrec/core/typing.py Normal file
View File

@ -0,0 +1,12 @@
from typing import Union
from .models import DanmuMsg, GiftSendMsg, GuardBuyMsg, SuperChatMsg
DanmakuMsg = Union[
DanmuMsg,
GiftSendMsg,
GuardBuyMsg,
SuperChatMsg,
]

View File

@ -1,19 +1,22 @@
from __future__ import annotations
import html
import asyncio
from typing import AsyncIterator, Final, List
from typing import AsyncIterator, Final, List, Any
from lxml import etree
import aiofiles
import attr
from .models import Metadata, Danmu
from .typing import Element
from .models import (
Metadata, Danmu, GiftSendRecord, GuardBuyRecord, SuperChatRecord
)
__all__ = 'DanmakuReader', 'DanmakuWriter'
class DanmakuReader:
class DanmakuReader: # TODO rewrite
def __init__(self, path: str) -> None:
self._path = path
@ -65,7 +68,9 @@ class DanmakuReader:
color=int(params[3]),
date=int(params[4]),
pool=int(params[5]),
uid=params[6],
uid_hash=params[6],
uid=elem.get('uid'),
uname=elem.get('user'),
dmid=int(params[7]),
text=elem.text,
)
@ -104,6 +109,15 @@ class DanmakuWriter:
async def write_danmu(self, danmu: Danmu) -> None:
await self._file.write(self._serialize_danmu(danmu))
async def write_gift_send_record(self, record: GiftSendRecord) -> None:
await self._file.write(self._serialize_gift_send_record(record))
async def write_guard_buy_record(self, record: GuardBuyRecord) -> None:
await self._file.write(self._serialize_guard_buy_record(record))
async def write_super_chat_record(self, record: SuperChatRecord) -> None:
await self._file.write(self._serialize_super_chat_record(record))
async def complete(self) -> None:
await self._file.write('</i>')
await self._file.close()
@ -123,8 +137,46 @@ class DanmakuWriter:
"""
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'
attrib = {
'p': (
f'{dm.stime:.3f},{dm.mode},{dm.size},{dm.color},'
f'{dm.date},{dm.pool},{dm.uid_hash},{dm.dmid}'
),
'uid': str(dm.uid),
'user': dm.uname,
}
elem = etree.Element('d', attrib=attrib)
elem.text = dm.text
return ' ' + etree.tostring(elem, encoding='utf8').decode() + '\n'
def _serialize_gift_send_record(self, record: GiftSendRecord) -> str:
attrib = attr.asdict(record, value_serializer=record_value_serializer)
elem = etree.Element('gift', attrib=attrib)
return ' ' + etree.tostring(elem, encoding='utf8').decode() + '\n'
def _serialize_guard_buy_record(self, record: GuardBuyRecord) -> str:
attrib = attr.asdict(record, value_serializer=record_value_serializer)
elem = etree.Element('guard', attrib=attrib)
return ' ' + etree.tostring(elem, encoding='utf8').decode() + '\n'
def _serialize_super_chat_record(self, record: SuperChatRecord) -> str:
attrib = attr.asdict(
record,
filter=lambda a, v: a.name != 'message',
value_serializer=record_value_serializer,
)
elem = etree.Element('sc', attrib=attrib)
elem.text = record.message
return ' ' + etree.tostring(elem, encoding='utf8').decode() + '\n'
def record_value_serializer(
instance: Any, attribute: attr.Attribute[Any], value: Any
) -> Any:
if attribute.name == 'ts':
return f'{value:.3f}'
if attribute.name == 'cointype':
return '金瓜子' if value == 'gold' else '银瓜子'
if not isinstance(value, str):
return str(value)
return value

View File

@ -1,4 +1,4 @@
from typing import Literal
import attr
@ -23,6 +23,40 @@ class Danmu:
color: int
date: int # milliseconds
pool: int
uid: str # uid hash
uid_hash: str
uid: int
uname: str
dmid: int
text: str
@attr.s(auto_attribs=True, slots=True, frozen=True)
class GiftSendRecord:
ts: float
uid: int
user: str
giftname: str
giftcount: int
cointype: Literal['sliver', 'gold']
price: int
@attr.s(auto_attribs=True, slots=True, frozen=True)
class GuardBuyRecord:
ts: float
uid: int
user: str
giftname: str
count: int
price: int
level: int
@attr.s(auto_attribs=True, slots=True, frozen=True)
class SuperChatRecord:
ts: float
uid: int
user: str
price: int
time: int
message: str

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,10 +6,10 @@
<link rel="icon" type="image/x-icon" href="assets/images/logo.png">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#1976d2">
<style>body,html{width:100%;height:100%;}*,:after,:before{box-sizing:border-box;}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:rgba(0,0,0,0);}body{margin:0;color:#000000d9;font-size:14px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-variant:tabular-nums;line-height:1.5715;background-color:#fff;font-feature-settings:"tnum","tnum";}html{--antd-wave-shadow-color:#1890ff;--scroll-bar:0;}</style><link rel="stylesheet" href="styles.09c5ed83b6748436b293.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="styles.09c5ed83b6748436b293.css"></noscript></head>
<style>body,html{width:100%;height:100%;}*,:after,:before{box-sizing:border-box;}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:rgba(0,0,0,0);}body{margin:0;color:#000000d9;font-size:14px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-variant:tabular-nums;line-height:1.5715;background-color:#fff;font-feature-settings:"tnum","tnum";}html{--antd-wave-shadow-color:#1890ff;--scroll-bar:0;}</style><link rel="stylesheet" href="styles.45ceee0fc92e0f47588f.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="styles.45ceee0fc92e0f47588f.css"></noscript></head>
<body>
<app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
<script src="runtime.4c79c38349615627b59b.js" defer></script><script src="polyfills.a427f031f0f7196ffda1.js" defer></script><script src="main.22f06d53efe81d9df3a8.js" defer></script>
<script src="runtime.984b6246826f9f569f66.js" defer></script><script src="polyfills.a427f031f0f7196ffda1.js" defer></script><script src="main.59cb7d8427d901ad6225.js" defer></script>
</body></html>

View File

@ -1,6 +1,6 @@
{
"configVersion": 1,
"timestamp": 1631943201826,
"timestamp": 1633590494074,
"index": "/index.html",
"assetGroups": [
{
@ -12,17 +12,17 @@
},
"urls": [
"/198.20d927ba29c55516dd2e.js",
"/51.275a97b45765f0cf1623.js",
"/659.4923e830b3feb2abcce2.js",
"/80.78cb9b41766e5c57d657.js",
"/51.c3a1708dc40a5461eaf3.js",
"/659.902c1857789597c9f3d8.js",
"/80.5aa4b3e3c4fcada93334.js",
"/954.05cbcc74da25eb3ef2a9.js",
"/common.fa68e1b34f0baff6ccad.js",
"/index.html",
"/main.22f06d53efe81d9df3a8.js",
"/main.59cb7d8427d901ad6225.js",
"/manifest.webmanifest",
"/polyfills.a427f031f0f7196ffda1.js",
"/runtime.4c79c38349615627b59b.js",
"/styles.09c5ed83b6748436b293.css"
"/runtime.984b6246826f9f569f66.js",
"/styles.45ceee0fc92e0f47588f.css"
],
"patterns": []
},
@ -1632,9 +1632,9 @@
"dataGroups": [],
"hashTable": {
"/198.20d927ba29c55516dd2e.js": "93bf87b6cc89e0a67c508c5c232d50d73c659057",
"/51.275a97b45765f0cf1623.js": "0bcd069e622a8682773ea72e425c191b391509cb",
"/659.4923e830b3feb2abcce2.js": "e74b2b9b53cd108ab7e91f765ca0926c42b3fd1f",
"/80.78cb9b41766e5c57d657.js": "ae52bd0e9cf819763e1f843ead3fb5adc10afffa",
"/51.c3a1708dc40a5461eaf3.js": "ef412bf0029f9923b62af368c185815503143c54",
"/659.902c1857789597c9f3d8.js": "eb0a200adbc8a60e97d96a5d9cd76054df561bdf",
"/80.5aa4b3e3c4fcada93334.js": "05503d5710b6b971c8117535de3839e37a23c149",
"/954.05cbcc74da25eb3ef2a9.js": "38c071c377d3a6d98902e679340d6a609996b717",
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
@ -3228,12 +3228,12 @@
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
"/common.fa68e1b34f0baff6ccad.js": "8e62b9aa49dde6486f74c6c94b97054743f1cc1b",
"/index.html": "98bb17017bf0130fb96ffae491a76787731b918c",
"/main.22f06d53efe81d9df3a8.js": "edb0ba67f76a4a734eaf210655790ca4926f45a6",
"/index.html": "feb7701a39ae28cb67aa8ec434fe2b35131f47a3",
"/main.59cb7d8427d901ad6225.js": "b35082dc068c552c032bb62c2d6dfa53a6d6532f",
"/manifest.webmanifest": "0c4534b4c868d756691b1b4372cecb2efce47c6d",
"/polyfills.a427f031f0f7196ffda1.js": "3e4560be48cd30e30bcbf51ca131a37d8c224fdc",
"/runtime.4c79c38349615627b59b.js": "f6de29446d188dd27541769e856ced4fa651e670",
"/styles.09c5ed83b6748436b293.css": "c08136817a63dadd53de8cb2df2d56ba95e5905a"
"/runtime.984b6246826f9f569f66.js": "9ef7243554b6cd1d20e1cc9eac2c5ce0c714082a",
"/styles.45ceee0fc92e0f47588f.css": "ebb9976bf7ee0e6443a627303522dd7c4ac1cff0"
},
"navigationUrls": [
{

View File

@ -1 +1 @@
(()=>{"use strict";var e,v={},m={};function r(e){var n=m[e];if(void 0!==n)return n.exports;var t=m[e]={exports:{}};return v[e].call(t.exports,t,t.exports,r),t.exports}r.m=v,e=[],r.O=(n,t,i,o)=>{if(!t){var a=1/0;for(f=0;f<e.length;f++){for(var[t,i,o]=e[f],d=!0,l=0;l<t.length;l++)(!1&o||a>=o)&&Object.keys(r.O).every(p=>r.O[p](t[l]))?t.splice(l--,1):(d=!1,o<a&&(a=o));if(d){e.splice(f--,1);var c=i();void 0!==c&&(n=c)}}return n}o=o||0;for(var f=e.length;f>0&&e[f-1][2]>o;f--)e[f]=e[f-1];e[f]=[t,i,o]},r.n=e=>{var n=e&&e.__esModule?()=>e.default:()=>e;return r.d(n,{a:n}),n},r.d=(e,n)=>{for(var t in n)r.o(n,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:n[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((n,t)=>(r.f[t](e,n),n),[])),r.u=e=>(592===e?"common":e)+"."+{51:"275a97b45765f0cf1623",80:"78cb9b41766e5c57d657",198:"20d927ba29c55516dd2e",592:"fa68e1b34f0baff6ccad",659:"4923e830b3feb2abcce2",954:"05cbcc74da25eb3ef2a9"}[e]+".js",r.miniCssF=e=>"styles.09c5ed83b6748436b293.css",r.o=(e,n)=>Object.prototype.hasOwnProperty.call(e,n),(()=>{var e={},n="blrec:";r.l=(t,i,o,f)=>{if(e[t])e[t].push(i);else{var a,d;if(void 0!==o)for(var l=document.getElementsByTagName("script"),c=0;c<l.length;c++){var u=l[c];if(u.getAttribute("src")==t||u.getAttribute("data-webpack")==n+o){a=u;break}}a||(d=!0,(a=document.createElement("script")).charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",n+o),a.src=r.tu(t)),e[t]=[i];var s=(g,p)=>{a.onerror=a.onload=null,clearTimeout(b);var _=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),_&&_.forEach(h=>h(p)),g)return g(p)},b=setTimeout(s.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=s.bind(null,a.onerror),a.onload=s.bind(null,a.onload),d&&document.head.appendChild(a)}}})(),r.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tu=n=>(void 0===e&&(e={createScriptURL:t=>t},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e.createScriptURL(n))})(),r.p="",(()=>{var e={666:0};r.f.j=(i,o)=>{var f=r.o(e,i)?e[i]:void 0;if(0!==f)if(f)o.push(f[2]);else if(666!=i){var a=new Promise((u,s)=>f=e[i]=[u,s]);o.push(f[2]=a);var d=r.p+r.u(i),l=new Error;r.l(d,u=>{if(r.o(e,i)&&(0!==(f=e[i])&&(e[i]=void 0),f)){var s=u&&("load"===u.type?"missing":u.type),b=u&&u.target&&u.target.src;l.message="Loading chunk "+i+" failed.\n("+s+": "+b+")",l.name="ChunkLoadError",l.type=s,l.request=b,f[1](l)}},"chunk-"+i,i)}else e[i]=0},r.O.j=i=>0===e[i];var n=(i,o)=>{var l,c,[f,a,d]=o,u=0;for(l in a)r.o(a,l)&&(r.m[l]=a[l]);if(d)var s=d(r);for(i&&i(o);u<f.length;u++)r.o(e,c=f[u])&&e[c]&&e[c][0](),e[f[u]]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(n.bind(null,0)),t.push=n.bind(null,t.push.bind(t))})()})();
(()=>{"use strict";var e,v={},m={};function r(e){var n=m[e];if(void 0!==n)return n.exports;var t=m[e]={exports:{}};return v[e].call(t.exports,t,t.exports,r),t.exports}r.m=v,e=[],r.O=(n,t,i,o)=>{if(!t){var a=1/0;for(f=0;f<e.length;f++){for(var[t,i,o]=e[f],d=!0,l=0;l<t.length;l++)(!1&o||a>=o)&&Object.keys(r.O).every(p=>r.O[p](t[l]))?t.splice(l--,1):(d=!1,o<a&&(a=o));if(d){e.splice(f--,1);var c=i();void 0!==c&&(n=c)}}return n}o=o||0;for(var f=e.length;f>0&&e[f-1][2]>o;f--)e[f]=e[f-1];e[f]=[t,i,o]},r.n=e=>{var n=e&&e.__esModule?()=>e.default:()=>e;return r.d(n,{a:n}),n},r.d=(e,n)=>{for(var t in n)r.o(n,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:n[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((n,t)=>(r.f[t](e,n),n),[])),r.u=e=>(592===e?"common":e)+"."+{51:"c3a1708dc40a5461eaf3",80:"5aa4b3e3c4fcada93334",198:"20d927ba29c55516dd2e",592:"fa68e1b34f0baff6ccad",659:"902c1857789597c9f3d8",954:"05cbcc74da25eb3ef2a9"}[e]+".js",r.miniCssF=e=>"styles.45ceee0fc92e0f47588f.css",r.o=(e,n)=>Object.prototype.hasOwnProperty.call(e,n),(()=>{var e={},n="blrec:";r.l=(t,i,o,f)=>{if(e[t])e[t].push(i);else{var a,d;if(void 0!==o)for(var l=document.getElementsByTagName("script"),c=0;c<l.length;c++){var u=l[c];if(u.getAttribute("src")==t||u.getAttribute("data-webpack")==n+o){a=u;break}}a||(d=!0,(a=document.createElement("script")).charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",n+o),a.src=r.tu(t)),e[t]=[i];var s=(g,p)=>{a.onerror=a.onload=null,clearTimeout(b);var _=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),_&&_.forEach(h=>h(p)),g)return g(p)},b=setTimeout(s.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=s.bind(null,a.onerror),a.onload=s.bind(null,a.onload),d&&document.head.appendChild(a)}}})(),r.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tu=n=>(void 0===e&&(e={createScriptURL:t=>t},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e.createScriptURL(n))})(),r.p="",(()=>{var e={666:0};r.f.j=(i,o)=>{var f=r.o(e,i)?e[i]:void 0;if(0!==f)if(f)o.push(f[2]);else if(666!=i){var a=new Promise((u,s)=>f=e[i]=[u,s]);o.push(f[2]=a);var d=r.p+r.u(i),l=new Error;r.l(d,u=>{if(r.o(e,i)&&(0!==(f=e[i])&&(e[i]=void 0),f)){var s=u&&("load"===u.type?"missing":u.type),b=u&&u.target&&u.target.src;l.message="Loading chunk "+i+" failed.\n("+s+": "+b+")",l.name="ChunkLoadError",l.type=s,l.request=b,f[1](l)}},"chunk-"+i,i)}else e[i]=0},r.O.j=i=>0===e[i];var n=(i,o)=>{var l,c,[f,a,d]=o,u=0;for(l in a)r.o(a,l)&&(r.m[l]=a[l]);if(d)var s=d(r);for(i&&i(o);u<f.length;u++)r.o(e,c=f[u])&&e[c]&&e[c][0](),e[f[u]]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(n.bind(null,0)),t.push=n.bind(null,t.push.bind(t))})()})();

View File

@ -2,6 +2,7 @@ from .helpers import (
file_exists,
create_file,
danmaku_path,
raw_danmaku_path,
extra_metadata_path,
)
@ -10,5 +11,6 @@ __all__ = (
'file_exists',
'create_file',
'danmaku_path',
'raw_danmaku_path',
'extra_metadata_path',
)

View File

@ -6,6 +6,7 @@ __all__ = (
'file_exists',
'create_file',
'danmaku_path',
'raw_danmaku_path',
'extra_metadata_path',
)
@ -24,5 +25,9 @@ def danmaku_path(video_path: str) -> str:
return str(PurePath(video_path).with_suffix('.xml'))
def raw_danmaku_path(video_path: str) -> str:
return str(PurePath(video_path).with_suffix('.jsonl'))
def extra_metadata_path(video_path: str) -> str:
return video_path + '.meta.json'

View File

@ -132,10 +132,18 @@ class HeaderSettings(HeaderOptions):
class DanmakuOptions(BaseModel):
danmu_uname: Optional[bool]
record_gift_send: Optional[bool]
record_guard_buy: Optional[bool]
record_super_chat: Optional[bool]
save_raw_danmaku: Optional[bool]
class DanmakuSettings(DanmakuOptions):
danmu_uname: bool = False
record_gift_send: bool = True
record_guard_buy: bool = True
record_super_chat: bool = True
save_raw_danmaku: bool = False
class RecorderOptions(BaseModel):

View File

@ -46,6 +46,10 @@ class TaskParam:
cookie: str
# DanmakuSettings
danmu_uname: bool
record_gift_send: bool
record_guard_buy: bool
record_super_chat: bool
save_raw_danmaku: bool
# RecorderSettings
quality_number: QualityNumber
read_timeout: int

View File

@ -32,6 +32,10 @@ class RecordTask:
cookie: str = '',
user_agent: str = '',
danmu_uname: bool = False,
record_gift_send: bool = False,
record_guard_buy: bool = False,
record_super_chat: bool = False,
save_raw_danmaku: bool = False,
buffer_size: Optional[int] = None,
read_timeout: Optional[int] = None,
filesize_limit: int = 0,
@ -49,6 +53,10 @@ class RecordTask:
self._cookie = cookie
self._user_agent = user_agent
self._danmu_uname = danmu_uname
self._record_gift_send = record_gift_send
self._record_guard_buy = record_guard_buy
self._record_super_chat = record_super_chat
self._save_raw_danmaku = save_raw_danmaku
self._buffer_size = buffer_size
self._read_timeout = read_timeout
self._filesize_limit = filesize_limit
@ -149,6 +157,38 @@ class RecordTask:
def danmu_uname(self, value: bool) -> None:
self._recorder.danmu_uname = value
@property
def record_gift_send(self) -> bool:
return self._recorder.record_gift_send
@record_gift_send.setter
def record_gift_send(self, value: bool) -> None:
self._recorder.record_gift_send = value
@property
def record_guard_buy(self) -> bool:
return self._recorder.record_guard_buy
@record_guard_buy.setter
def record_guard_buy(self, value: bool) -> None:
self._recorder.record_guard_buy = value
@property
def record_super_chat(self) -> bool:
return self._recorder.record_super_chat
@record_super_chat.setter
def record_super_chat(self, value: bool) -> None:
self._recorder.record_super_chat = value
@property
def save_raw_danmaku(self) -> bool:
return self._recorder.save_raw_danmaku
@save_raw_danmaku.setter
def save_raw_danmaku(self, value: bool) -> None:
self._recorder.save_raw_danmaku = value
@property
def quality_number(self) -> QualityNumber:
return self._recorder.quality_number
@ -322,6 +362,10 @@ class RecordTask:
buffer_size=self._buffer_size,
read_timeout=self._read_timeout,
danmu_uname=self._danmu_uname,
record_gift_send=self._record_gift_send,
record_guard_buy=self._record_guard_buy,
record_super_chat=self._record_super_chat,
save_raw_danmaku=self._save_raw_danmaku,
filesize_limit=self._filesize_limit,
duration_limit=self._duration_limit,
)

View File

@ -205,6 +205,10 @@ class RecordTaskManager:
) -> None:
task = self._get_task(room_id)
task.danmu_uname = settings.danmu_uname
task.record_gift_send = settings.record_gift_send
task.record_guard_buy = settings.record_guard_buy
task.record_super_chat = settings.record_super_chat
task.save_raw_danmaku = settings.save_raw_danmaku
def apply_task_recorder_settings(
self, room_id: int, settings: RecorderSettings
@ -236,6 +240,10 @@ class RecordTaskManager:
user_agent=task.user_agent,
cookie=task.cookie,
danmu_uname=task.danmu_uname,
record_gift_send=task.record_gift_send,
record_guard_buy=task.record_guard_buy,
record_super_chat=task.record_super_chat,
save_raw_danmaku=task.save_raw_danmaku,
quality_number=task.quality_number,
read_timeout=task.read_timeout,
buffer_size=task.buffer_size,

View File

@ -7,7 +7,7 @@
[nzTheme]="theme"
[nzTrigger]="null"
nzCollapsible
[nzCollapsedWidth]="65"
[nzCollapsedWidth]="57"
[(nzCollapsed)]="collapsed"
>
<a href="/" title="Home" alt="Home">

View File

@ -1,7 +1,7 @@
@use './shared/styles/layout';
@use './shared/styles/common';
$app-header-height: 64px;
$app-header-height: 56px;
$app-logo-size: 32px;
:host {

View File

@ -1,10 +1,61 @@
<form nz-form [formGroup]="settingsForm">
<nz-form-item class="setting-item" appSwitchActionable>
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="记录送礼信息到弹幕文件里"
>记录送礼</nz-form-label
>
<nz-form-control
class="setting-control switch"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.recordGiftSend ? recordGiftSendControl : 'warning'
"
>
<nz-switch formControlName="recordGiftSend"></nz-switch>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item" appSwitchActionable>
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="记录上舰信息到弹幕文件里"
>记录上舰</nz-form-label
>
<nz-form-control
class="setting-control switch"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.recordGuardBuy ? recordGuardBuyControl : 'warning'
"
>
<nz-switch formControlName="recordGuardBuy"></nz-switch>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item" appSwitchActionable>
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="记录 Super Chat 信息到弹幕文件里"
>记录 Super Chat</nz-form-label
>
<nz-form-control
class="setting-control switch"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.recordSuperChat ? recordSuperChatControl : 'warning'
"
>
<nz-switch formControlName="recordSuperChat"></nz-switch>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item" appSwitchActionable>
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="发送者: 弹幕内容"
>弹幕加用户名</nz-form-label
>弹幕加用户名</nz-form-label
>
<nz-form-control
class="setting-control switch"
@ -14,4 +65,21 @@
<nz-switch formControlName="danmuUname"></nz-switch>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item" appSwitchActionable>
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="保存原始弹幕到 JSON lines 文件,主要用于分析调试。"
>保存原始弹幕</nz-form-label
>
<nz-form-control
class="setting-control switch"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.saveRawDanmaku ? saveRawDanmakuControl : 'warning'
"
>
<nz-switch formControlName="saveRawDanmaku"></nz-switch>
</nz-form-control>
</nz-form-item>
</form>

View File

@ -39,6 +39,10 @@ export class DanmakuSettingsComponent implements OnInit, OnChanges {
) {
this.settingsForm = formBuilder.group({
danmuUname: [''],
recordGiftSend: [''],
recordGuardBuy: [''],
recordSuperChat: [''],
saveRawDanmaku: [''],
});
}
@ -46,6 +50,22 @@ export class DanmakuSettingsComponent implements OnInit, OnChanges {
return this.settingsForm.get('danmuUname') as FormControl;
}
get recordGiftSendControl() {
return this.settingsForm.get('recordGiftSend') as FormControl;
}
get recordGuardBuyControl() {
return this.settingsForm.get('recordGuardBuy') as FormControl;
}
get recordSuperChatControl() {
return this.settingsForm.get('recordSuperChat') as FormControl;
}
get saveRawDanmakuControl() {
return this.settingsForm.get('saveRawDanmaku') as FormControl;
}
ngOnChanges(): void {
this.syncStatus = mapValues(this.settings, () => true);
this.settingsForm.setValue(this.settings);

View File

@ -23,8 +23,8 @@
>
<ng-template #deleteSourceTip>
<p>
自动: 转换成功才删除源文件<br />
从不: 转换后保留源文件<br />
自动: 转换成功才删除源文件<br />
从不: 转换后总是保留源文件<br />
</p>
</ng-template>
<nz-form-control

View File

@ -21,7 +21,7 @@
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="超时时间设置得比较长相对不容易因网络不稳定而出现流中断,但是一旦出现中断就无法实现无缝拼接且漏录时长越长。"
nzTooltipTitle="超时时间设置得比较长相对不容易因网络不稳定而出现流中断,但是一旦出现中断就无法实现无缝拼接且漏录较多。"
>数据读取超时</nz-form-label
>
<nz-form-control

View File

@ -3,4 +3,5 @@
.inner-content {
@extend %inner-content;
padding-top: 0;
}

View File

@ -9,6 +9,10 @@ export type HeaderOptions = Nullable<HeaderSettings>;
export interface DanmakuSettings {
danmuUname: boolean;
recordGiftSend: boolean;
recordGuardBuy: boolean;
recordSuperChat: boolean;
saveRawDanmaku: boolean;
}
export type DanmakuOptions = Nullable<DanmakuSettings>;

View File

@ -16,7 +16,7 @@
display: block;
margin: 0;
padding: 1rem;
background: #f1f3f4;
background: #f1f1f1;
overflow: auto;
}

View File

@ -138,7 +138,10 @@
>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label class="setting-label" nzNoColon
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="超时时间设置得比较长相对不容易因网络不稳定而出现流中断,但是一旦出现中断就无法实现无缝拼接且漏录较多。"
>数据读取超时</nz-form-label
>
<nz-form-control
@ -167,7 +170,10 @@
>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label class="setting-label" nzNoColon
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="硬盘写入缓冲设置得比较大可以减少对硬盘的写入,但需要占用更多的内存。"
>硬盘写入缓冲</nz-form-label
>
<nz-form-control class="setting-control select">
@ -196,8 +202,93 @@
<div ngModelGroup="danmaku" class="form-group danmaku">
<h2>弹幕</h2>
<nz-form-item class="setting-item">
<nz-form-label class="setting-label" nzFor="danmuUname" nzNoColon
>弹幕加用户名</nz-form-label
<nz-form-label
class="setting-label"
nzFor="recordGiftSend"
nzNoColon
nzTooltipTitle="记录送礼信息到弹幕文件里"
>记录送礼</nz-form-label
>
<nz-form-control class="setting-control switch">
<nz-switch
id="recordGiftSend"
name="recordGiftSend"
[(ngModel)]="model.danmaku.recordGiftSend"
[disabled]="options.danmaku.recordGiftSend === null"
></nz-switch>
</nz-form-control>
<label
nz-checkbox
[nzChecked]="options.danmaku.recordGiftSend !== null"
(nzCheckedChange)="
options.danmaku.recordGiftSend = $event
? globalSettings.danmaku.recordGiftSend
: null
"
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
nzFor="recordGuardBuy"
nzNoColon
nzTooltipTitle="记录上舰信息到弹幕文件里"
>记录上舰</nz-form-label
>
<nz-form-control class="setting-control switch">
<nz-switch
id="recordGuardBuy"
name="recordGuardBuy"
[(ngModel)]="model.danmaku.recordGuardBuy"
[disabled]="options.danmaku.recordGuardBuy === null"
></nz-switch>
</nz-form-control>
<label
nz-checkbox
[nzChecked]="options.danmaku.recordGuardBuy !== null"
(nzCheckedChange)="
options.danmaku.recordGuardBuy = $event
? globalSettings.danmaku.recordGuardBuy
: null
"
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
nzFor="recordSuperChat"
nzNoColon
nzTooltipTitle="记录 Super Chat 信息到弹幕文件里"
>记录 Super Chat</nz-form-label
>
<nz-form-control class="setting-control switch">
<nz-switch
id="recordSuperChat"
name="recordSuperChat"
[(ngModel)]="model.danmaku.recordSuperChat"
[disabled]="options.danmaku.recordSuperChat === null"
></nz-switch>
</nz-form-control>
<label
nz-checkbox
[nzChecked]="options.danmaku.recordSuperChat !== null"
(nzCheckedChange)="
options.danmaku.recordSuperChat = $event
? globalSettings.danmaku.recordSuperChat
: null
"
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
nzFor="danmuUname"
nzNoColon
nzTooltipTitle="发送者: 弹幕内容"
>弹幕前加用户名</nz-form-label
>
<nz-form-control class="setting-control switch">
<nz-switch
@ -218,6 +309,33 @@
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
nzFor="saveRawDanmaku"
nzNoColon
nzTooltipTitle="保存原始弹幕到 JSON lines 文件,主要用于分析调试。"
>保存原始弹幕</nz-form-label
>
<nz-form-control class="setting-control switch">
<nz-switch
id="saveRawDanmaku"
name="saveRawDanmaku"
[(ngModel)]="model.danmaku.saveRawDanmaku"
[disabled]="options.danmaku.saveRawDanmaku === null"
></nz-switch>
</nz-form-control>
<label
nz-checkbox
[nzChecked]="options.danmaku.saveRawDanmaku !== null"
(nzCheckedChange)="
options.danmaku.saveRawDanmaku = $event
? globalSettings.danmaku.saveRawDanmaku
: null
"
>覆盖全局设置</label
>
</nz-form-item>
</div>
<div ngModelGroup="postprocessing" class="form-group postprocessing">
@ -248,9 +366,18 @@
>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label class="setting-label" nzNoColon
<nz-form-label
class="setting-label"
nzNoColon
[nzTooltipTitle]="deleteSourceTip"
>源文件删除策略</nz-form-label
>
<ng-template #deleteSourceTip>
<p>
自动: 转换成功才删除源文件<br />
从不: 转换后总是保留源文件<br />
</p>
</ng-template>
<nz-form-control class="setting-control select">
<nz-select
name="deleteSource"

View File

@ -1,4 +1,4 @@
@use '../shared/styles/drawer';
@use "../shared/styles/drawer";
.controls-wrapper {
display: flex;
@ -8,8 +8,7 @@
width: 100%;
padding: 0.2em;
background: #eee;
border: 1px solid #d9d9d9;
background: #f9f9f9;
border-left: none;
border-right: none;

View File

@ -1,7 +1,7 @@
/* You can add global styles to this file, and also import other style files */
@media screen and (min-width: 768px) {
::-webkit-scrollbar {
width: 10px;
width: 8px;
}
::-webkit-scrollbar-track {

View File

@ -1,6 +1,6 @@
https://raw.githubusercontent.com/acgnhiki/bitarray-win-wheels/main/2.2.5/bitarray-2.2.5-cp38-cp38-win_amd64.whl ; platform_system == 'Windows' and '64' in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.8.0'
https://raw.githubusercontent.com/acgnhiki/bitarray-win-wheels/main/2.2.5/bitarray-2.2.5-cp39-cp39-win_amd64.whl ; platform_system == 'Windows' and '64' in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.9.0'
https://raw.githubusercontent.com/acgnhiki/bitarray-win-wheels/main/2.2.5/bitarray-2.2.5-cp310-cp310-win_amd64.whl ; platform_system == 'Windows' and '64' in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.10.0'
https://raw.githubusercontent.com/acgnhiki/bitarray-win-wheels/main/2.2.5/bitarray-2.2.5-cp38-cp38-win32.whl ; platform_system == 'Windows' and '64' not in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.8.0'
https://raw.githubusercontent.com/acgnhiki/bitarray-win-wheels/main/2.2.5/bitarray-2.2.5-cp39-cp39-win32.whl ; platform_system == 'Windows' and '64' not in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.9.0'
https://raw.githubusercontent.com/acgnhiki/bitarray-win-wheels/main/2.2.5/bitarray-2.2.5-cp310-cp310-win32.whl ; platform_system == 'Windows' and '64' not in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.10.0'
https://cdn.jsdelivr.net/gh/acgnhiki/bitarray-win-wheels@main/2.2.5/bitarray-2.2.5-cp38-cp38-win_amd64.whl ; platform_system == 'Windows' and '64' in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.8.0'
https://cdn.jsdelivr.net/gh/acgnhiki/bitarray-win-wheels@main/2.2.5/bitarray-2.2.5-cp39-cp39-win_amd64.whl ; platform_system == 'Windows' and '64' in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.9.0'
https://cdn.jsdelivr.net/gh/acgnhiki/bitarray-win-wheels@main/2.2.5/bitarray-2.2.5-cp310-cp310-win_amd64.whl ; platform_system == 'Windows' and '64' in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.10.0'
https://cdn.jsdelivr.net/gh/acgnhiki/bitarray-win-wheels@main/2.2.5/bitarray-2.2.5-cp38-cp38-win32.whl ; platform_system == 'Windows' and '64' not in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.8.0'
https://cdn.jsdelivr.net/gh/acgnhiki/bitarray-win-wheels@main/2.2.5/bitarray-2.2.5-cp39-cp39-win32.whl ; platform_system == 'Windows' and '64' not in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.9.0'
https://cdn.jsdelivr.net/gh/acgnhiki/bitarray-win-wheels@main/2.2.5/bitarray-2.2.5-cp310-cp310-win32.whl ; platform_system == 'Windows' and '64' not in platform_machine and platform_python_implementation == 'CPython' and python_version ~= '3.10.0'