release: v1.1.0
This commit is contained in:
parent
6b61738d09
commit
672c3f094e
@ -1,5 +1,12 @@
|
||||
# 更新日志
|
||||
|
||||
## 1.1.0
|
||||
|
||||
- 支持记录送物、上舰、醒目留言到弹幕文件
|
||||
- 支持保存原始弹幕为 JSON lines 文件
|
||||
- 弹幕协议更新了,更新弹幕客户端支持新的弹幕协议。
|
||||
- 对前端界面样式做了些微调整
|
||||
|
||||
## 1.0.5
|
||||
|
||||
- 修复路径模板设置的模板变量显示不完整
|
||||
|
76
FAQ.md
Normal file
76
FAQ.md
Normal 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
136
README.md
@ -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 进行录制,不能很好地解决漏录、数据损坏、录播文件片段化等录播问题。尤其录制的直播很不稳定,结果很不尽如人意。
|
||||
|
||||
---
|
||||
|
||||
## 赞助 & 支持
|
||||
|
||||
如果觉得这个工具好用,对你有所帮助,可以投喂支持亿下哦~
|
||||
|
@ -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]
|
||||
|
@ -1,4 +1,4 @@
|
||||
|
||||
__prog__ = 'blrec'
|
||||
__version__ = '1.0.5'
|
||||
__version__ = '1.1.0'
|
||||
__github__ = 'https://github.com/acgnhiki/blrec'
|
||||
|
@ -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']
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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']),
|
||||
)
|
||||
|
77
src/blrec/core/raw_danmaku_dumper.py
Normal file
77
src/blrec/core/raw_danmaku_dumper.py
Normal 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')
|
45
src/blrec/core/raw_danmaku_receiver.py
Normal file
45
src/blrec/core/raw_danmaku_receiver.py
Normal 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)
|
@ -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
12
src/blrec/core/typing.py
Normal file
@ -0,0 +1,12 @@
|
||||
from typing import Union
|
||||
|
||||
|
||||
from .models import DanmuMsg, GiftSendMsg, GuardBuyMsg, SuperChatMsg
|
||||
|
||||
|
||||
DanmakuMsg = Union[
|
||||
DanmuMsg,
|
||||
GiftSendMsg,
|
||||
GuardBuyMsg,
|
||||
SuperChatMsg,
|
||||
]
|
@ -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
|
||||
|
@ -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
1
src/blrec/data/webapp/80.5aa4b3e3c4fcada93334.js
Normal file
1
src/blrec/data/webapp/80.5aa4b3e3c4fcada93334.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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>
|
File diff suppressed because one or more lines are too long
@ -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": [
|
||||
{
|
||||
|
@ -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))})()})();
|
File diff suppressed because one or more lines are too long
@ -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',
|
||||
)
|
||||
|
@ -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'
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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,
|
||||
|
@ -7,7 +7,7 @@
|
||||
[nzTheme]="theme"
|
||||
[nzTrigger]="null"
|
||||
nzCollapsible
|
||||
[nzCollapsedWidth]="65"
|
||||
[nzCollapsedWidth]="57"
|
||||
[(nzCollapsed)]="collapsed"
|
||||
>
|
||||
<a href="/" title="Home" alt="Home">
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -23,8 +23,8 @@
|
||||
>
|
||||
<ng-template #deleteSourceTip>
|
||||
<p>
|
||||
自动: 转换成功才会删除源文件<br />
|
||||
从不: 转换后保留源文件<br />
|
||||
自动: 转换成功才删除源文件<br />
|
||||
从不: 转换后总是保留源文件<br />
|
||||
</p>
|
||||
</ng-template>
|
||||
<nz-form-control
|
||||
|
@ -21,7 +21,7 @@
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
nzTooltipTitle="超时时间设置得比较长相对不容易因网络不稳定而出现流中断,但是一旦出现中断就无法实现无缝拼接且漏录时长越长。"
|
||||
nzTooltipTitle="超时时间设置得比较长相对不容易因网络不稳定而出现流中断,但是一旦出现中断就无法实现无缝拼接且漏录较多。"
|
||||
>数据读取超时</nz-form-label
|
||||
>
|
||||
<nz-form-control
|
||||
|
@ -3,4 +3,5 @@
|
||||
|
||||
.inner-content {
|
||||
@extend %inner-content;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
@ -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>;
|
||||
|
@ -16,7 +16,7 @@
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
background: #f1f3f4;
|
||||
background: #f1f1f1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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'
|
Loading…
Reference in New Issue
Block a user