parent
451e744ceb
commit
34b10a8a2d
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*/__pycache__*
|
||||||
|
*/.mypy_cache*
|
12
CHANGELOG.md
12
CHANGELOG.md
@ -1,5 +1,17 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
|
## 1.4.0
|
||||||
|
|
||||||
|
- 适应数据有问题的流服务器 gotcha08 (issue #13)
|
||||||
|
- 支持 Docker (issue #15)
|
||||||
|
- 修复弹幕录制出错 (issue #16)
|
||||||
|
- 弹幕文件统一礼物价格单位 (issue #18)
|
||||||
|
- Webhook 支持更多事件 (issue #19)
|
||||||
|
- 文件名重复自动加后缀 (issue #20)
|
||||||
|
- 记录免费礼物到弹幕文件为可选的
|
||||||
|
- 加强 api-key 的安全性
|
||||||
|
- 其它一些重构调整
|
||||||
|
|
||||||
## 1.3.2
|
## 1.3.2
|
||||||
|
|
||||||
- 修复录制错误: `AssertionError: Invalid Tag`
|
- 修复录制错误: `AssertionError: Invalid Tag`
|
||||||
|
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM python:3.10-slim-buster
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
VOLUME /rec
|
||||||
|
|
||||||
|
COPY src src/
|
||||||
|
COPY setup.py setup.cfg .
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential python3-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& pip3 install --no-cache-dir -e . \
|
||||||
|
&& apt-get purge -y --auto-remove build-essential python3-dev
|
||||||
|
# ref: https://github.com/docker-library/python/issues/60#issuecomment-134322383
|
||||||
|
|
||||||
|
ENTRYPOINT ["blrec", "-o", "/rec", "--host", "0.0.0.0"]
|
||||||
|
CMD ["-c", "/rec/settings.toml"]
|
21
Dockerfile.mirrors
Normal file
21
Dockerfile.mirrors
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM python:3.10-slim-buster
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
VOLUME /rec
|
||||||
|
|
||||||
|
COPY src src/
|
||||||
|
COPY setup.py setup.cfg .
|
||||||
|
|
||||||
|
RUN sed -i "s/deb.debian.org/mirrors.aliyun.com/g" /etc/apt/sources.list \
|
||||||
|
&& sed -i "s/security.debian.org/mirrors.aliyun.com/g" /etc/apt/sources.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential python3-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& pip3 install -i https://mirrors.aliyun.com/pypi/simple --no-cache-dir -e . \
|
||||||
|
&& apt-get purge -y --auto-remove build-essential python3-dev
|
||||||
|
# ref: https://github.com/docker-library/python/issues/60#issuecomment-134322383
|
||||||
|
|
||||||
|
ENTRYPOINT ["blrec", "-o", "/rec", "--host", "0.0.0.0"]
|
||||||
|
CMD ["-c", "/rec/settings.toml"]
|
17
README.md
17
README.md
@ -72,6 +72,23 @@
|
|||||||
|
|
||||||
删除解压后的文件夹
|
删除解压后的文件夹
|
||||||
|
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
- 默认参数
|
||||||
|
|
||||||
|
`docker run -v ~/blrec:/rec -dp 2233:2233 acgnhiki/blrec`
|
||||||
|
|
||||||
|
- 指定参数
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -v ~/blrec:/rec -dp 2233:2233 acgnhiki/blrec \
|
||||||
|
-c ~/blrec/settings.toml \
|
||||||
|
--key-file path/to/key-file \
|
||||||
|
--cert-file path/to/cert-file \
|
||||||
|
--api-key bili2233
|
||||||
|
```
|
||||||
|
|
||||||
## 使用方法
|
## 使用方法
|
||||||
|
|
||||||
### 使用默认设置文件和保存位置
|
### 使用默认设置文件和保存位置
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
|
|
||||||
__prog__ = 'blrec'
|
__prog__ = 'blrec'
|
||||||
__version__ = '1.3.2'
|
__version__ = '1.4.0'
|
||||||
__github__ = 'https://github.com/acgnhiki/blrec'
|
__github__ = 'https://github.com/acgnhiki/blrec'
|
||||||
|
@ -103,7 +103,7 @@ class Application:
|
|||||||
async def launch(self) -> None:
|
async def launch(self) -> None:
|
||||||
self._setup()
|
self._setup()
|
||||||
await self._task_manager.load_all_tasks()
|
await self._task_manager.load_all_tasks()
|
||||||
logger.info('Launched Application')
|
logger.info(f'Launched Application v{__version__}')
|
||||||
|
|
||||||
async def exit(self) -> None:
|
async def exit(self) -> None:
|
||||||
await self._exit()
|
await self._exit()
|
||||||
@ -136,50 +136,61 @@ class Application:
|
|||||||
|
|
||||||
settings = await self._settings_manager.add_task_settings(room_id)
|
settings = await self._settings_manager.add_task_settings(room_id)
|
||||||
await self._task_manager.add_task(settings)
|
await self._task_manager.add_task(settings)
|
||||||
|
logger.info(f'Added task: {room_id}')
|
||||||
|
|
||||||
return room_id
|
return room_id
|
||||||
|
|
||||||
async def remove_task(self, room_id: int) -> None:
|
async def remove_task(self, room_id: int) -> None:
|
||||||
await self._task_manager.remove_task(room_id)
|
await self._task_manager.remove_task(room_id)
|
||||||
await self._settings_manager.remove_task_settings(room_id)
|
await self._settings_manager.remove_task_settings(room_id)
|
||||||
|
logger.info(f'Removed task: {room_id}')
|
||||||
|
|
||||||
async def remove_all_tasks(self) -> None:
|
async def remove_all_tasks(self) -> None:
|
||||||
await self._task_manager.remove_all_tasks()
|
await self._task_manager.remove_all_tasks()
|
||||||
await self._settings_manager.remove_all_task_settings()
|
await self._settings_manager.remove_all_task_settings()
|
||||||
|
logger.info('Removed all tasks')
|
||||||
|
|
||||||
async def start_task(self, room_id: int) -> None:
|
async def start_task(self, room_id: int) -> None:
|
||||||
await self._task_manager.start_task(room_id)
|
await self._task_manager.start_task(room_id)
|
||||||
await self._settings_manager.mark_task_enabled(room_id)
|
await self._settings_manager.mark_task_enabled(room_id)
|
||||||
|
logger.info(f'Started task: {room_id}')
|
||||||
|
|
||||||
async def stop_task(self, room_id: int, force: bool = False) -> None:
|
async def stop_task(self, room_id: int, force: bool = False) -> None:
|
||||||
await self._task_manager.stop_task(room_id, force)
|
await self._task_manager.stop_task(room_id, force)
|
||||||
await self._settings_manager.mark_task_disabled(room_id)
|
await self._settings_manager.mark_task_disabled(room_id)
|
||||||
|
logger.info(f'Stopped task: {room_id}')
|
||||||
|
|
||||||
async def start_all_tasks(self) -> None:
|
async def start_all_tasks(self) -> None:
|
||||||
await self._task_manager.start_all_tasks()
|
await self._task_manager.start_all_tasks()
|
||||||
await self._settings_manager.mark_all_tasks_enabled()
|
await self._settings_manager.mark_all_tasks_enabled()
|
||||||
|
logger.info('Started all tasks')
|
||||||
|
|
||||||
async def stop_all_tasks(self, force: bool = False) -> None:
|
async def stop_all_tasks(self, force: bool = False) -> None:
|
||||||
await self._task_manager.stop_all_tasks(force)
|
await self._task_manager.stop_all_tasks(force)
|
||||||
await self._settings_manager.mark_all_tasks_disabled()
|
await self._settings_manager.mark_all_tasks_disabled()
|
||||||
|
logger.info('Stopped all tasks')
|
||||||
|
|
||||||
async def enable_task_recorder(self, room_id: int) -> None:
|
async def enable_task_recorder(self, room_id: int) -> None:
|
||||||
await self._task_manager.enable_task_recorder(room_id)
|
await self._task_manager.enable_task_recorder(room_id)
|
||||||
await self._settings_manager.mark_task_recorder_enabled(room_id)
|
await self._settings_manager.mark_task_recorder_enabled(room_id)
|
||||||
|
logger.info(f'Enabled task recorder: {room_id}')
|
||||||
|
|
||||||
async def disable_task_recorder(
|
async def disable_task_recorder(
|
||||||
self, room_id: int, force: bool = False
|
self, room_id: int, force: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
await self._task_manager.disable_task_recorder(room_id, force)
|
await self._task_manager.disable_task_recorder(room_id, force)
|
||||||
await self._settings_manager.mark_task_recorder_disabled(room_id)
|
await self._settings_manager.mark_task_recorder_disabled(room_id)
|
||||||
|
logger.info(f'Disabled task recorder: {room_id}')
|
||||||
|
|
||||||
async def enable_all_task_recorders(self) -> None:
|
async def enable_all_task_recorders(self) -> None:
|
||||||
await self._task_manager.enable_all_task_recorders()
|
await self._task_manager.enable_all_task_recorders()
|
||||||
await self._settings_manager.mark_all_task_recorders_enabled()
|
await self._settings_manager.mark_all_task_recorders_enabled()
|
||||||
|
logger.info('Enabled all task recorders')
|
||||||
|
|
||||||
async def disable_all_task_recorders(self, force: bool = False) -> None:
|
async def disable_all_task_recorders(self, force: bool = False) -> None:
|
||||||
await self._task_manager.disable_all_task_recorders(force)
|
await self._task_manager.disable_all_task_recorders(force)
|
||||||
await self._settings_manager.mark_all_task_recorders_disabled()
|
await self._settings_manager.mark_all_task_recorders_disabled()
|
||||||
|
logger.info('Disabled all task recorders')
|
||||||
|
|
||||||
def get_task_data(self, room_id: int) -> TaskData:
|
def get_task_data(self, room_id: int) -> TaskData:
|
||||||
return self._task_manager.get_task_data(room_id)
|
return self._task_manager.get_task_data(room_id)
|
||||||
|
@ -68,11 +68,11 @@ class WebApi:
|
|||||||
params = {
|
params = {
|
||||||
'room_id': room_id,
|
'room_id': room_id,
|
||||||
'protocol': '0,1',
|
'protocol': '0,1',
|
||||||
'format': '0,2',
|
'format': '0,1,2',
|
||||||
'codec': '0,1',
|
'codec': '0,1',
|
||||||
'qn': qn,
|
'qn': qn,
|
||||||
'platform': 'web',
|
'platform': 'web',
|
||||||
'ptype': 16,
|
'ptype': 8,
|
||||||
}
|
}
|
||||||
r = await self._get(self.GET_ROOM_PLAY_INFO_URL, params=params)
|
r = await self._get(self.GET_ROOM_PLAY_INFO_URL, params=params)
|
||||||
return r['data']
|
return r['data']
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
from typing import Dict, List, Optional, cast
|
from typing import Dict, List, cast
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from tenacity import (
|
from tenacity import (
|
||||||
@ -165,9 +165,11 @@ class Live:
|
|||||||
# the timestamp on the server at the moment in seconds
|
# the timestamp on the server at the moment in seconds
|
||||||
return await self._api.get_timestamp()
|
return await self._api.get_timestamp()
|
||||||
|
|
||||||
async def get_live_stream_url(
|
async def get_live_stream_urls(
|
||||||
self, qn: QualityNumber = 10000, format: StreamFormat = 'flv'
|
self,
|
||||||
) -> Optional[str]:
|
qn: QualityNumber = 10000,
|
||||||
|
format: StreamFormat = 'flv',
|
||||||
|
) -> List[str]:
|
||||||
try:
|
try:
|
||||||
data = await self._api.get_room_play_info(self._room_id, qn)
|
data = await self._api.get_room_play_info(self._room_id, qn)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -188,12 +190,13 @@ class Live:
|
|||||||
accept_qn = cast(List[QualityNumber], codec['accept_qn'])
|
accept_qn = cast(List[QualityNumber], codec['accept_qn'])
|
||||||
|
|
||||||
if qn not in accept_qn:
|
if qn not in accept_qn:
|
||||||
return None
|
return []
|
||||||
|
|
||||||
assert codec['current_qn'] == qn
|
assert codec['current_qn'] == qn
|
||||||
url_info = codec['url_info'][0]
|
|
||||||
|
|
||||||
return url_info['host'] + codec['base_url'] + url_info['extra']
|
return [
|
||||||
|
i['host'] + codec['base_url'] + i['extra']
|
||||||
|
for i in codec['url_info']
|
||||||
|
]
|
||||||
|
|
||||||
def _check_room_play_info(self, data: ResponseData) -> None:
|
def _check_room_play_info(self, data: ResponseData) -> None:
|
||||||
if data['is_hidden']:
|
if data['is_hidden']:
|
||||||
|
@ -15,6 +15,7 @@ QualityNumber = Literal[
|
|||||||
|
|
||||||
StreamFormat = Literal[
|
StreamFormat = Literal[
|
||||||
'flv',
|
'flv',
|
||||||
|
'ts',
|
||||||
'fmp4',
|
'fmp4',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -4,17 +4,21 @@ import logging
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from typing import Iterator, List, Optional
|
from typing import Iterator, List, Optional
|
||||||
|
|
||||||
from blrec.core.models import GiftSendMsg, GuardBuyMsg, SuperChatMsg
|
from tenacity import (
|
||||||
|
AsyncRetrying,
|
||||||
|
stop_after_attempt,
|
||||||
|
retry_if_not_exception_type,
|
||||||
|
)
|
||||||
|
|
||||||
from .. import __version__, __prog__, __github__
|
from .. import __version__, __prog__, __github__
|
||||||
from .danmaku_receiver import DanmakuReceiver, DanmuMsg
|
from .danmaku_receiver import DanmakuReceiver, DanmuMsg
|
||||||
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
||||||
from .statistics import StatisticsCalculator
|
from .statistics import StatisticsCalculator
|
||||||
from ..bili.live import Live
|
from ..bili.live import Live
|
||||||
from ..exception import exception_callback
|
from ..exception import exception_callback, submit_exception
|
||||||
from ..event.event_emitter import EventListener, EventEmitter
|
from ..event.event_emitter import EventListener, EventEmitter
|
||||||
from ..path import danmaku_path
|
from ..path import danmaku_path
|
||||||
|
from ..core.models import GiftSendMsg, GuardBuyMsg, SuperChatMsg
|
||||||
from ..danmaku.models import (
|
from ..danmaku.models import (
|
||||||
Metadata, Danmu, GiftSendRecord, GuardBuyRecord, SuperChatRecord
|
Metadata, Danmu, GiftSendRecord, GuardBuyRecord, SuperChatRecord
|
||||||
)
|
)
|
||||||
@ -50,6 +54,7 @@ class DanmakuDumper(
|
|||||||
*,
|
*,
|
||||||
danmu_uname: bool = False,
|
danmu_uname: bool = False,
|
||||||
record_gift_send: bool = False,
|
record_gift_send: bool = False,
|
||||||
|
record_free_gifts: bool = False,
|
||||||
record_guard_buy: bool = False,
|
record_guard_buy: bool = False,
|
||||||
record_super_chat: bool = False,
|
record_super_chat: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -61,6 +66,7 @@ class DanmakuDumper(
|
|||||||
|
|
||||||
self.danmu_uname = danmu_uname
|
self.danmu_uname = danmu_uname
|
||||||
self.record_gift_send = record_gift_send
|
self.record_gift_send = record_gift_send
|
||||||
|
self.record_free_gifts = record_free_gifts
|
||||||
self.record_guard_buy = record_guard_buy
|
self.record_guard_buy = record_guard_buy
|
||||||
self.record_super_chat = record_super_chat
|
self.record_super_chat = record_super_chat
|
||||||
|
|
||||||
@ -124,7 +130,7 @@ class DanmakuDumper(
|
|||||||
await self._cancel_dump_task()
|
await self._cancel_dump_task()
|
||||||
|
|
||||||
def _create_dump_task(self) -> None:
|
def _create_dump_task(self) -> None:
|
||||||
self._dump_task = asyncio.create_task(self._dump())
|
self._dump_task = asyncio.create_task(self._do_dump())
|
||||||
self._dump_task.add_done_callback(exception_callback)
|
self._dump_task.add_done_callback(exception_callback)
|
||||||
|
|
||||||
async def _cancel_dump_task(self) -> None:
|
async def _cancel_dump_task(self) -> None:
|
||||||
@ -133,7 +139,7 @@ class DanmakuDumper(
|
|||||||
await self._dump_task
|
await self._dump_task
|
||||||
|
|
||||||
@aio_task_with_room_id
|
@aio_task_with_room_id
|
||||||
async def _dump(self) -> None:
|
async def _do_dump(self) -> None:
|
||||||
assert self._path is not None
|
assert self._path is not None
|
||||||
logger.debug('Started dumping danmaku')
|
logger.debug('Started dumping danmaku')
|
||||||
self._calculator.reset()
|
self._calculator.reset()
|
||||||
@ -144,6 +150,25 @@ class DanmakuDumper(
|
|||||||
await self._emit('danmaku_file_created', self._path)
|
await self._emit('danmaku_file_created', self._path)
|
||||||
await writer.write_metadata(self._make_metadata())
|
await writer.write_metadata(self._make_metadata())
|
||||||
|
|
||||||
|
async for attempt in AsyncRetrying(
|
||||||
|
retry=retry_if_not_exception_type((
|
||||||
|
asyncio.CancelledError
|
||||||
|
)),
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
):
|
||||||
|
with attempt:
|
||||||
|
try:
|
||||||
|
await self._dumping_loop(writer)
|
||||||
|
except Exception as e:
|
||||||
|
submit_exception(e)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
logger.info(f"Danmaku file completed: '{self._path}'")
|
||||||
|
await self._emit('danmaku_file_completed', self._path)
|
||||||
|
logger.debug('Stopped dumping danmaku')
|
||||||
|
self._calculator.freeze()
|
||||||
|
|
||||||
|
async def _dumping_loop(self, writer: DanmakuWriter) -> None:
|
||||||
while True:
|
while True:
|
||||||
msg = await self._receiver.get_message()
|
msg = await self._receiver.get_message()
|
||||||
if isinstance(msg, DanmuMsg):
|
if isinstance(msg, DanmuMsg):
|
||||||
@ -152,9 +177,13 @@ class DanmakuDumper(
|
|||||||
elif isinstance(msg, GiftSendMsg):
|
elif isinstance(msg, GiftSendMsg):
|
||||||
if not self.record_gift_send:
|
if not self.record_gift_send:
|
||||||
continue
|
continue
|
||||||
await writer.write_gift_send_record(
|
record = self._make_gift_send_record(msg)
|
||||||
self._make_gift_send_record(msg)
|
if (
|
||||||
)
|
not self.record_free_gifts and
|
||||||
|
record.is_free_gift()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
await writer.write_gift_send_record(record)
|
||||||
elif isinstance(msg, GuardBuyMsg):
|
elif isinstance(msg, GuardBuyMsg):
|
||||||
if not self.record_guard_buy:
|
if not self.record_guard_buy:
|
||||||
continue
|
continue
|
||||||
@ -169,11 +198,6 @@ class DanmakuDumper(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning('Unsupported message type:', repr(msg))
|
logger.warning('Unsupported message type:', repr(msg))
|
||||||
finally:
|
|
||||||
logger.info(f"Danmaku file completed: '{self._path}'")
|
|
||||||
await self._emit('danmaku_file_completed', self._path)
|
|
||||||
logger.debug('Stopped dumping danmaku')
|
|
||||||
self._calculator.freeze()
|
|
||||||
|
|
||||||
def _make_metadata(self) -> Metadata:
|
def _make_metadata(self) -> Metadata:
|
||||||
return Metadata(
|
return Metadata(
|
||||||
@ -235,7 +259,7 @@ class DanmakuDumper(
|
|||||||
ts=self._calc_stime(msg.timestamp * 1000),
|
ts=self._calc_stime(msg.timestamp * 1000),
|
||||||
uid=msg.uid,
|
uid=msg.uid,
|
||||||
user=msg.uname,
|
user=msg.uname,
|
||||||
price=msg.price,
|
price=msg.price * msg.rate,
|
||||||
time=msg.time,
|
time=msg.time,
|
||||||
message=msg.message,
|
message=msg.message,
|
||||||
)
|
)
|
||||||
|
@ -92,6 +92,7 @@ class SuperChatMsg:
|
|||||||
gift_name: str
|
gift_name: str
|
||||||
count: int
|
count: int
|
||||||
price: int
|
price: int
|
||||||
|
rate: int
|
||||||
time: int # duration in seconds
|
time: int # duration in seconds
|
||||||
message: str
|
message: str
|
||||||
uid: int
|
uid: int
|
||||||
@ -105,6 +106,7 @@ class SuperChatMsg:
|
|||||||
gift_name=data['gift']['gift_name'],
|
gift_name=data['gift']['gift_name'],
|
||||||
count=int(data['gift']['num']),
|
count=int(data['gift']['num']),
|
||||||
price=int(data['price']),
|
price=int(data['price']),
|
||||||
|
rate=int(data['rate']),
|
||||||
time=int(data['time']),
|
time=int(data['time']),
|
||||||
message=data['message'],
|
message=data['message'],
|
||||||
uid=int(data['uid']),
|
uid=int(data['uid']),
|
||||||
|
@ -4,22 +4,41 @@ import logging
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
from aiofiles.threadpool.text import AsyncTextIOWrapper
|
||||||
|
from tenacity import (
|
||||||
|
AsyncRetrying,
|
||||||
|
stop_after_attempt,
|
||||||
|
retry_if_not_exception_type,
|
||||||
|
)
|
||||||
|
|
||||||
from .raw_danmaku_receiver import RawDanmakuReceiver
|
from .raw_danmaku_receiver import RawDanmakuReceiver
|
||||||
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
||||||
from ..exception import exception_callback
|
from ..exception import exception_callback, submit_exception
|
||||||
|
from ..event.event_emitter import EventListener, EventEmitter
|
||||||
from ..path import raw_danmaku_path
|
from ..path import raw_danmaku_path
|
||||||
from ..utils.mixins import SwitchableMixin
|
from ..utils.mixins import SwitchableMixin
|
||||||
from ..logging.room_id import aio_task_with_room_id
|
from ..logging.room_id import aio_task_with_room_id
|
||||||
|
|
||||||
|
|
||||||
__all__ = 'RawDanmakuDumper',
|
__all__ = 'RawDanmakuDumper', 'RawDanmakuDumperEventListener'
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class RawDanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
|
class RawDanmakuDumperEventListener(EventListener):
|
||||||
|
async def on_raw_danmaku_file_created(self, path: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def on_raw_danmaku_file_completed(self, path: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class RawDanmakuDumper(
|
||||||
|
EventEmitter[RawDanmakuDumperEventListener],
|
||||||
|
StreamRecorderEventListener,
|
||||||
|
SwitchableMixin,
|
||||||
|
):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
stream_recorder: StreamRecorder,
|
stream_recorder: StreamRecorder,
|
||||||
@ -53,7 +72,7 @@ class RawDanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
|
|||||||
await self._cancel_dump_task()
|
await self._cancel_dump_task()
|
||||||
|
|
||||||
def _create_dump_task(self) -> None:
|
def _create_dump_task(self) -> None:
|
||||||
self._dump_task = asyncio.create_task(self._dump())
|
self._dump_task = asyncio.create_task(self._do_dump())
|
||||||
self._dump_task.add_done_callback(exception_callback)
|
self._dump_task.add_done_callback(exception_callback)
|
||||||
|
|
||||||
async def _cancel_dump_task(self) -> None:
|
async def _cancel_dump_task(self) -> None:
|
||||||
@ -62,16 +81,36 @@ class RawDanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
|
|||||||
await self._dump_task
|
await self._dump_task
|
||||||
|
|
||||||
@aio_task_with_room_id
|
@aio_task_with_room_id
|
||||||
async def _dump(self) -> None:
|
async def _do_dump(self) -> None:
|
||||||
logger.debug('Started dumping raw danmaku')
|
logger.debug('Started dumping raw danmaku')
|
||||||
try:
|
try:
|
||||||
async with aiofiles.open(self._path, 'wt', encoding='utf8') as f:
|
async with aiofiles.open(self._path, 'wt', encoding='utf8') as f:
|
||||||
logger.info(f"Raw danmaku file created: '{self._path}'")
|
logger.info(f"Raw danmaku file created: '{self._path}'")
|
||||||
|
await self._emit('raw_danmaku_file_created', self._path)
|
||||||
|
|
||||||
|
async for attempt in AsyncRetrying(
|
||||||
|
retry=retry_if_not_exception_type((
|
||||||
|
asyncio.CancelledError
|
||||||
|
)),
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
):
|
||||||
|
with attempt:
|
||||||
|
try:
|
||||||
|
await self._dumping_loop(f)
|
||||||
|
except Exception as e:
|
||||||
|
submit_exception(e)
|
||||||
|
raise
|
||||||
while True:
|
while True:
|
||||||
danmu = await self._receiver.get_raw_danmaku()
|
danmu = await self._receiver.get_raw_danmaku()
|
||||||
json_string = json.dumps(danmu, ensure_ascii=False)
|
json_string = json.dumps(danmu, ensure_ascii=False)
|
||||||
await f.write(json_string + '\n')
|
await f.write(json_string + '\n')
|
||||||
finally:
|
finally:
|
||||||
logger.info(f"Raw danmaku file completed: '{self._path}'")
|
logger.info(f"Raw danmaku file completed: '{self._path}'")
|
||||||
|
await self._emit('raw_danmaku_file_completed', self._path)
|
||||||
logger.debug('Stopped dumping raw danmaku')
|
logger.debug('Stopped dumping raw danmaku')
|
||||||
|
|
||||||
|
async def _dumping_loop(self, file: AsyncTextIOWrapper) -> None:
|
||||||
|
while True:
|
||||||
|
danmu = await self._receiver.get_raw_danmaku()
|
||||||
|
json_string = json.dumps(danmu, ensure_ascii=False)
|
||||||
|
await file.write(json_string + '\n')
|
||||||
|
@ -11,7 +11,7 @@ from tenacity import retry, wait_fixed, stop_after_attempt
|
|||||||
from .danmaku_receiver import DanmakuReceiver
|
from .danmaku_receiver import DanmakuReceiver
|
||||||
from .danmaku_dumper import DanmakuDumper, DanmakuDumperEventListener
|
from .danmaku_dumper import DanmakuDumper, DanmakuDumperEventListener
|
||||||
from .raw_danmaku_receiver import RawDanmakuReceiver
|
from .raw_danmaku_receiver import RawDanmakuReceiver
|
||||||
from .raw_danmaku_dumper import RawDanmakuDumper
|
from .raw_danmaku_dumper import RawDanmakuDumper, RawDanmakuDumperEventListener
|
||||||
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
||||||
from ..event.event_emitter import EventListener, EventEmitter
|
from ..event.event_emitter import EventListener, EventEmitter
|
||||||
from ..bili.live import Live
|
from ..bili.live import Live
|
||||||
@ -41,17 +41,33 @@ class RecorderEventListener(EventListener):
|
|||||||
...
|
...
|
||||||
|
|
||||||
async def on_video_file_created(
|
async def on_video_file_created(
|
||||||
self, path: str, record_start_time: int
|
self, recorder: Recorder, path: str
|
||||||
) -> None:
|
) -> None:
|
||||||
...
|
...
|
||||||
|
|
||||||
async def on_video_file_completed(self, path: str) -> None:
|
async def on_video_file_completed(
|
||||||
|
self, recorder: Recorder, path: str
|
||||||
|
) -> None:
|
||||||
...
|
...
|
||||||
|
|
||||||
async def on_danmaku_file_created(self, path: str) -> None:
|
async def on_danmaku_file_created(
|
||||||
|
self, recorder: Recorder, path: str
|
||||||
|
) -> None:
|
||||||
...
|
...
|
||||||
|
|
||||||
async def on_danmaku_file_completed(self, path: str) -> None:
|
async def on_danmaku_file_completed(
|
||||||
|
self, recorder: Recorder, path: str
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def on_raw_danmaku_file_created(
|
||||||
|
self, recorder: Recorder, path: str
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def on_raw_danmaku_file_completed(
|
||||||
|
self, recorder: Recorder, path: str
|
||||||
|
) -> None:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
@ -60,6 +76,7 @@ class Recorder(
|
|||||||
LiveEventListener,
|
LiveEventListener,
|
||||||
AsyncStoppableMixin,
|
AsyncStoppableMixin,
|
||||||
DanmakuDumperEventListener,
|
DanmakuDumperEventListener,
|
||||||
|
RawDanmakuDumperEventListener,
|
||||||
StreamRecorderEventListener,
|
StreamRecorderEventListener,
|
||||||
):
|
):
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -75,6 +92,7 @@ class Recorder(
|
|||||||
disconnection_timeout: Optional[int] = None,
|
disconnection_timeout: Optional[int] = None,
|
||||||
danmu_uname: bool = False,
|
danmu_uname: bool = False,
|
||||||
record_gift_send: bool = False,
|
record_gift_send: bool = False,
|
||||||
|
record_free_gifts: bool = False,
|
||||||
record_guard_buy: bool = False,
|
record_guard_buy: bool = False,
|
||||||
record_super_chat: bool = False,
|
record_super_chat: bool = False,
|
||||||
save_cover: bool = False,
|
save_cover: bool = False,
|
||||||
@ -110,6 +128,7 @@ class Recorder(
|
|||||||
self._danmaku_receiver,
|
self._danmaku_receiver,
|
||||||
danmu_uname=danmu_uname,
|
danmu_uname=danmu_uname,
|
||||||
record_gift_send=record_gift_send,
|
record_gift_send=record_gift_send,
|
||||||
|
record_free_gifts=record_free_gifts,
|
||||||
record_guard_buy=record_guard_buy,
|
record_guard_buy=record_guard_buy,
|
||||||
record_super_chat=record_super_chat,
|
record_super_chat=record_super_chat,
|
||||||
)
|
)
|
||||||
@ -119,6 +138,10 @@ class Recorder(
|
|||||||
self._raw_danmaku_receiver,
|
self._raw_danmaku_receiver,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def live(self) -> Live:
|
||||||
|
return self._live
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def recording(self) -> bool:
|
def recording(self) -> bool:
|
||||||
return self._recording
|
return self._recording
|
||||||
@ -175,6 +198,14 @@ class Recorder(
|
|||||||
def record_gift_send(self, value: bool) -> None:
|
def record_gift_send(self, value: bool) -> None:
|
||||||
self._danmaku_dumper.record_gift_send = value
|
self._danmaku_dumper.record_gift_send = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def record_free_gifts(self) -> bool:
|
||||||
|
return self._danmaku_dumper.record_free_gifts
|
||||||
|
|
||||||
|
@record_free_gifts.setter
|
||||||
|
def record_free_gifts(self, value: bool) -> None:
|
||||||
|
self._danmaku_dumper.record_free_gifts = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def record_guard_buy(self) -> bool:
|
def record_guard_buy(self) -> bool:
|
||||||
return self._danmaku_dumper.record_guard_buy
|
return self._danmaku_dumper.record_guard_buy
|
||||||
@ -246,6 +277,7 @@ class Recorder(
|
|||||||
async def _do_start(self) -> None:
|
async def _do_start(self) -> None:
|
||||||
self._live_monitor.add_listener(self)
|
self._live_monitor.add_listener(self)
|
||||||
self._danmaku_dumper.add_listener(self)
|
self._danmaku_dumper.add_listener(self)
|
||||||
|
self._raw_danmaku_dumper.add_listener(self)
|
||||||
self._stream_recorder.add_listener(self)
|
self._stream_recorder.add_listener(self)
|
||||||
logger.debug('Started recorder')
|
logger.debug('Started recorder')
|
||||||
|
|
||||||
@ -259,6 +291,7 @@ class Recorder(
|
|||||||
await self._stop_recording()
|
await self._stop_recording()
|
||||||
self._live_monitor.remove_listener(self)
|
self._live_monitor.remove_listener(self)
|
||||||
self._danmaku_dumper.remove_listener(self)
|
self._danmaku_dumper.remove_listener(self)
|
||||||
|
self._raw_danmaku_dumper.remove_listener(self)
|
||||||
self._stream_recorder.remove_listener(self)
|
self._stream_recorder.remove_listener(self)
|
||||||
logger.debug('Stopped recorder')
|
logger.debug('Stopped recorder')
|
||||||
|
|
||||||
@ -306,18 +339,24 @@ class Recorder(
|
|||||||
async def on_video_file_created(
|
async def on_video_file_created(
|
||||||
self, path: str, record_start_time: int
|
self, path: str, record_start_time: int
|
||||||
) -> None:
|
) -> None:
|
||||||
await self._emit('video_file_created', path, record_start_time)
|
await self._emit('video_file_created', self, path)
|
||||||
|
|
||||||
async def on_video_file_completed(self, path: str) -> None:
|
async def on_video_file_completed(self, path: str) -> None:
|
||||||
await self._emit('video_file_completed', path)
|
await self._emit('video_file_completed', self, path)
|
||||||
if self.save_cover:
|
if self.save_cover:
|
||||||
await self._save_cover_image(path)
|
await self._save_cover_image(path)
|
||||||
|
|
||||||
async def on_danmaku_file_created(self, path: str) -> None:
|
async def on_danmaku_file_created(self, path: str) -> None:
|
||||||
await self._emit('danmaku_file_created', path)
|
await self._emit('danmaku_file_created', self, path)
|
||||||
|
|
||||||
async def on_danmaku_file_completed(self, path: str) -> None:
|
async def on_danmaku_file_completed(self, path: str) -> None:
|
||||||
await self._emit('danmaku_file_completed', path)
|
await self._emit('danmaku_file_completed', self, path)
|
||||||
|
|
||||||
|
async def on_raw_danmaku_file_created(self, path: str) -> None:
|
||||||
|
await self._emit('raw_danmaku_file_created', self, path)
|
||||||
|
|
||||||
|
async def on_raw_danmaku_file_completed(self, path: str) -> None:
|
||||||
|
await self._emit('raw_danmaku_file_completed', self, path)
|
||||||
|
|
||||||
async def on_stream_recording_stopped(self) -> None:
|
async def on_stream_recording_stopped(self) -> None:
|
||||||
logger.debug('Stream recording stopped')
|
logger.debug('Stream recording stopped')
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import errno
|
import errno
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -40,7 +41,7 @@ from ..bili.typing import QualityNumber
|
|||||||
from ..flv.stream_processor import StreamProcessor, BaseOutputFileManager
|
from ..flv.stream_processor import StreamProcessor, BaseOutputFileManager
|
||||||
from ..utils.mixins import AsyncCooperationMix, AsyncStoppableMixin
|
from ..utils.mixins import AsyncCooperationMix, AsyncStoppableMixin
|
||||||
from ..path import escape_path
|
from ..path import escape_path
|
||||||
from ..flv.exceptions import FlvStreamCorruptedError
|
from ..flv.exceptions import FlvDataError, FlvStreamCorruptedError
|
||||||
from ..bili.exceptions import (
|
from ..bili.exceptions import (
|
||||||
LiveRoomHidden, LiveRoomLocked, LiveRoomEncrypted, NoStreamUrlAvailable
|
LiveRoomHidden, LiveRoomLocked, LiveRoomEncrypted, NoStreamUrlAvailable
|
||||||
)
|
)
|
||||||
@ -224,6 +225,7 @@ class StreamRecorder(
|
|||||||
|
|
||||||
def _run(self) -> None:
|
def _run(self) -> None:
|
||||||
self._calculator.reset()
|
self._calculator.reset()
|
||||||
|
self._use_candidate_stream: bool = False
|
||||||
try:
|
try:
|
||||||
with tqdm(
|
with tqdm(
|
||||||
desc='Recording',
|
desc='Recording',
|
||||||
@ -297,6 +299,7 @@ class StreamRecorder(
|
|||||||
self._stopped = True
|
self._stopped = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
|
self._handle_exception(e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _streaming_loop(self) -> None:
|
def _streaming_loop(self) -> None:
|
||||||
@ -333,6 +336,10 @@ class StreamRecorder(
|
|||||||
self._stopped = True
|
self._stopped = True
|
||||||
else:
|
else:
|
||||||
logger.debug('Connection recovered')
|
logger.debug('Connection recovered')
|
||||||
|
except FlvDataError as e:
|
||||||
|
logger.warning(repr(e))
|
||||||
|
self._use_candidate_stream = not self._use_candidate_stream
|
||||||
|
url = self._get_live_stream_url()
|
||||||
except FlvStreamCorruptedError as e:
|
except FlvStreamCorruptedError as e:
|
||||||
logger.warning(repr(e))
|
logger.warning(repr(e))
|
||||||
url = self._get_live_stream_url()
|
url = self._get_live_stream_url()
|
||||||
@ -368,10 +375,14 @@ class StreamRecorder(
|
|||||||
)
|
)
|
||||||
def _get_live_stream_url(self) -> str:
|
def _get_live_stream_url(self) -> str:
|
||||||
qn = self._real_quality_number or self.quality_number
|
qn = self._real_quality_number or self.quality_number
|
||||||
url = self._run_coroutine(self._live.get_live_stream_url(qn, 'flv'))
|
logger.debug(
|
||||||
|
'Getting the live stream url... '
|
||||||
|
f'qn: {qn}, use_candidate_stream: {self._use_candidate_stream}'
|
||||||
|
)
|
||||||
|
urls = self._run_coroutine(self._live.get_live_stream_urls(qn, 'flv'))
|
||||||
|
|
||||||
if self._real_quality_number is None:
|
if self._real_quality_number is None:
|
||||||
if url is None:
|
if not urls:
|
||||||
logger.info(
|
logger.info(
|
||||||
f'The specified video quality ({qn}) is not available, '
|
f'The specified video quality ({qn}) is not available, '
|
||||||
'using the original video quality (10000) instead.'
|
'using the original video quality (10000) instead.'
|
||||||
@ -382,7 +393,17 @@ class StreamRecorder(
|
|||||||
logger.info(f'The specified video quality ({qn}) is available')
|
logger.info(f'The specified video quality ({qn}) is available')
|
||||||
self._real_quality_number = self.quality_number
|
self._real_quality_number = self.quality_number
|
||||||
|
|
||||||
assert url is not None
|
if not self._use_candidate_stream:
|
||||||
|
url = urls[0]
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
url = urls[1]
|
||||||
|
except IndexError:
|
||||||
|
logger.debug(
|
||||||
|
'no candidate stream url available, '
|
||||||
|
'using the primary stream url instead.'
|
||||||
|
)
|
||||||
|
url = urls[0]
|
||||||
logger.debug(f"Got live stream url: '{url}'")
|
logger.debug(f"Got live stream url: '{url}'")
|
||||||
|
|
||||||
return url
|
return url
|
||||||
@ -545,9 +566,17 @@ class OutputFileManager(BaseOutputFileManager, AsyncCooperationMix):
|
|||||||
second=str(date_time.second).rjust(2, '0'),
|
second=str(date_time.second).rjust(2, '0'),
|
||||||
)
|
)
|
||||||
|
|
||||||
full_pathname = os.path.abspath(
|
pathname = os.path.abspath(
|
||||||
os.path.expanduser(os.path.join(self.out_dir, relpath) + '.flv')
|
os.path.expanduser(os.path.join(self.out_dir, relpath) + '.flv')
|
||||||
)
|
)
|
||||||
os.makedirs(os.path.dirname(full_pathname), exist_ok=True)
|
os.makedirs(os.path.dirname(pathname), exist_ok=True)
|
||||||
|
while os.path.exists(pathname):
|
||||||
|
root, ext = os.path.splitext(pathname)
|
||||||
|
m = re.search(r'_\((\d+)\)$', root)
|
||||||
|
if m is None:
|
||||||
|
root += '_(1)'
|
||||||
|
else:
|
||||||
|
root = re.sub(r'\(\d+\)$', f'({int(m.group(1)) + 1})', root)
|
||||||
|
pathname = root + ext
|
||||||
|
|
||||||
return full_pathname
|
return pathname
|
||||||
|
@ -156,13 +156,22 @@ class DanmakuWriter:
|
|||||||
'uid': str(dm.uid),
|
'uid': str(dm.uid),
|
||||||
'user': dm.uname,
|
'user': dm.uname,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
elem = etree.Element('d', attrib=attrib)
|
elem = etree.Element('d', attrib=attrib)
|
||||||
|
except ValueError:
|
||||||
|
# ValueError: All strings must be XML compatible: Unicode or ASCII,
|
||||||
|
# no NULL bytes or control characters
|
||||||
|
attrib['user'] = remove_control_characters(dm.uname)
|
||||||
|
elem = etree.Element('d', attrib=attrib)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
elem.text = dm.text
|
elem.text = dm.text
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# ValueError: All strings must be XML compatible: Unicode or ASCII,
|
# ValueError: All strings must be XML compatible: Unicode or ASCII,
|
||||||
# no NULL bytes or control characters
|
# no NULL bytes or control characters
|
||||||
elem.text = remove_control_characters(dm.text)
|
elem.text = remove_control_characters(dm.text)
|
||||||
|
|
||||||
return ' ' + etree.tostring(elem, encoding='utf8').decode() + '\n'
|
return ' ' + etree.tostring(elem, encoding='utf8').decode() + '\n'
|
||||||
|
|
||||||
def _serialize_gift_send_record(self, record: GiftSendRecord) -> str:
|
def _serialize_gift_send_record(self, record: GiftSendRecord) -> str:
|
||||||
|
@ -40,6 +40,9 @@ class GiftSendRecord:
|
|||||||
cointype: Literal['sliver', 'gold']
|
cointype: Literal['sliver', 'gold']
|
||||||
price: int
|
price: int
|
||||||
|
|
||||||
|
def is_free_gift(self) -> bool:
|
||||||
|
return self.cointype != 'gold'
|
||||||
|
|
||||||
|
|
||||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||||
class GuardBuyRecord:
|
class GuardBuyRecord:
|
||||||
|
File diff suppressed because one or more lines are too long
1
src/blrec/data/webapp/622.03823b1714105423.js
Normal file
1
src/blrec/data/webapp/622.03823b1714105423.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
File diff suppressed because one or more lines are too long
@ -10,6 +10,6 @@
|
|||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
<noscript>Please enable JavaScript to continue using this application.</noscript>
|
<noscript>Please enable JavaScript to continue using this application.</noscript>
|
||||||
<script src="runtime.c48b962c8225f379.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.7a4da5a70e652c3f.js" type="module"></script>
|
<script src="runtime.0c70df55750c11ae.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.5e1124f1a8c47971.js" type="module"></script>
|
||||||
|
|
||||||
</body></html>
|
</body></html>
|
1
src/blrec/data/webapp/main.5e1124f1a8c47971.js
Normal file
1
src/blrec/data/webapp/main.5e1124f1a8c47971.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
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"configVersion": 1,
|
"configVersion": 1,
|
||||||
"timestamp": 1642737806856,
|
"timestamp": 1644635235127,
|
||||||
"index": "/index.html",
|
"index": "/index.html",
|
||||||
"assetGroups": [
|
"assetGroups": [
|
||||||
{
|
{
|
||||||
@ -11,17 +11,17 @@
|
|||||||
"ignoreVary": true
|
"ignoreVary": true
|
||||||
},
|
},
|
||||||
"urls": [
|
"urls": [
|
||||||
"/103.9c8251484169c949.js",
|
"/103.5b5d2a6e5a8a7479.js",
|
||||||
"/146.92e3b29c4c754544.js",
|
"/146.92e3b29c4c754544.js",
|
||||||
"/622.dd6c6ac77555edc7.js",
|
"/622.03823b1714105423.js",
|
||||||
"/66.97582e026891bf70.js",
|
"/66.97582e026891bf70.js",
|
||||||
"/853.5697121b2e654d67.js",
|
"/853.84ee7e1d7cff8913.js",
|
||||||
"/common.858f777e9296e6f2.js",
|
"/common.858f777e9296e6f2.js",
|
||||||
"/index.html",
|
"/index.html",
|
||||||
"/main.7a4da5a70e652c3f.js",
|
"/main.5e1124f1a8c47971.js",
|
||||||
"/manifest.webmanifest",
|
"/manifest.webmanifest",
|
||||||
"/polyfills.4b08448aee19bb22.js",
|
"/polyfills.4b08448aee19bb22.js",
|
||||||
"/runtime.c48b962c8225f379.js",
|
"/runtime.0c70df55750c11ae.js",
|
||||||
"/styles.1f581691b230dc4d.css"
|
"/styles.1f581691b230dc4d.css"
|
||||||
],
|
],
|
||||||
"patterns": []
|
"patterns": []
|
||||||
@ -1633,11 +1633,11 @@
|
|||||||
],
|
],
|
||||||
"dataGroups": [],
|
"dataGroups": [],
|
||||||
"hashTable": {
|
"hashTable": {
|
||||||
"/103.9c8251484169c949.js": "b111521f577092144d65506fa72c121543fd4446",
|
"/103.5b5d2a6e5a8a7479.js": "cc0240f217015b6d4ddcc14f31fcc42e1c1c282a",
|
||||||
"/146.92e3b29c4c754544.js": "3824de681dd1f982ea69a065cdf54d7a1e781f4d",
|
"/146.92e3b29c4c754544.js": "3824de681dd1f982ea69a065cdf54d7a1e781f4d",
|
||||||
"/622.dd6c6ac77555edc7.js": "5406594e418982532e138bf3c12ccbb1cfb09942",
|
"/622.03823b1714105423.js": "86c61c37b53c951370ef2a16eb187cda666d7562",
|
||||||
"/66.97582e026891bf70.js": "11cfd8acd3399fef42f0cf77d64aafc62c7e6994",
|
"/66.97582e026891bf70.js": "11cfd8acd3399fef42f0cf77d64aafc62c7e6994",
|
||||||
"/853.5697121b2e654d67.js": "beb26b99743f6363c59c477b18be790b1e70d56e",
|
"/853.84ee7e1d7cff8913.js": "6281853ef474fc543ac39fb47ec4a0a61ca875fa",
|
||||||
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
|
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
|
||||||
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
|
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
|
||||||
"/assets/fill/.gitkeep": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
|
"/assets/fill/.gitkeep": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
|
||||||
@ -3232,11 +3232,11 @@
|
|||||||
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
|
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
|
||||||
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
|
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
|
||||||
"/common.858f777e9296e6f2.js": "b68ca68e1e214a2537d96935c23410126cc564dd",
|
"/common.858f777e9296e6f2.js": "b68ca68e1e214a2537d96935c23410126cc564dd",
|
||||||
"/index.html": "0ed90c4296321165a7358a99ae67f911cc8b8a0f",
|
"/index.html": "9e332051ad11197ce464047d2a13bc15016a3d70",
|
||||||
"/main.7a4da5a70e652c3f.js": "6dd73cbf2d5aee6a29559f6357b1075aadd6fb99",
|
"/main.5e1124f1a8c47971.js": "325ed4fbaa0160c18e06bb646431d1fa86f826f1",
|
||||||
"/manifest.webmanifest": "0c4534b4c868d756691b1b4372cecb2efce47c6d",
|
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
|
||||||
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
|
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
|
||||||
"/runtime.c48b962c8225f379.js": "ae674dc840fe5aa957d08253f56e21c4b772b93b",
|
"/runtime.0c70df55750c11ae.js": "8e2f23cde1c56d8859adf4cd948753c1f8736d86",
|
||||||
"/styles.1f581691b230dc4d.css": "6f5befbbad57c2b2e80aae855139744b8010d150"
|
"/styles.1f581691b230dc4d.css": "6f5befbbad57c2b2e80aae855139744b8010d150"
|
||||||
},
|
},
|
||||||
"navigationUrls": [
|
"navigationUrls": [
|
||||||
|
@ -1 +1 @@
|
|||||||
(()=>{"use strict";var e,v={},m={};function r(e){var i=m[e];if(void 0!==i)return i.exports;var t=m[e]={exports:{}};return v[e].call(t.exports,t,t.exports,r),t.exports}r.m=v,e=[],r.O=(i,t,o,f)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,o,f]=e[n],c=!0,l=0;l<t.length;l++)(!1&f||a>=f)&&Object.keys(r.O).every(b=>r.O[b](t[l]))?t.splice(l--,1):(c=!1,f<a&&(a=f));if(c){e.splice(n--,1);var d=o();void 0!==d&&(i=d)}}return i}f=f||0;for(var n=e.length;n>0&&e[n-1][2]>f;n--)e[n]=e[n-1];e[n]=[t,o,f]},r.n=e=>{var i=e&&e.__esModule?()=>e.default:()=>e;return r.d(i,{a:i}),i},r.d=(e,i)=>{for(var t in i)r.o(i,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:i[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((i,t)=>(r.f[t](e,i),i),[])),r.u=e=>(592===e?"common":e)+"."+{66:"97582e026891bf70",103:"9c8251484169c949",146:"92e3b29c4c754544",592:"858f777e9296e6f2",622:"dd6c6ac77555edc7",853:"5697121b2e654d67"}[e]+".js",r.miniCssF=e=>{},r.o=(e,i)=>Object.prototype.hasOwnProperty.call(e,i),(()=>{var e={},i="blrec:";r.l=(t,o,f,n)=>{if(e[t])e[t].push(o);else{var a,c;if(void 0!==f)for(var l=document.getElementsByTagName("script"),d=0;d<l.length;d++){var u=l[d];if(u.getAttribute("src")==t||u.getAttribute("data-webpack")==i+f){a=u;break}}a||(c=!0,(a=document.createElement("script")).type="module",a.charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",i+f),a.src=r.tu(t)),e[t]=[o];var s=(g,b)=>{a.onerror=a.onload=null,clearTimeout(p);var _=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),_&&_.forEach(h=>h(b)),g)return g(b)},p=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),c&&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=i=>(void 0===e&&(e={createScriptURL:t=>t},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e.createScriptURL(i))})(),r.p="",(()=>{var e={666:0};r.f.j=(o,f)=>{var n=r.o(e,o)?e[o]:void 0;if(0!==n)if(n)f.push(n[2]);else if(666!=o){var a=new Promise((u,s)=>n=e[o]=[u,s]);f.push(n[2]=a);var c=r.p+r.u(o),l=new Error;r.l(c,u=>{if(r.o(e,o)&&(0!==(n=e[o])&&(e[o]=void 0),n)){var s=u&&("load"===u.type?"missing":u.type),p=u&&u.target&&u.target.src;l.message="Loading chunk "+o+" failed.\n("+s+": "+p+")",l.name="ChunkLoadError",l.type=s,l.request=p,n[1](l)}},"chunk-"+o,o)}else e[o]=0},r.O.j=o=>0===e[o];var i=(o,f)=>{var l,d,[n,a,c]=f,u=0;if(n.some(p=>0!==e[p])){for(l in a)r.o(a,l)&&(r.m[l]=a[l]);if(c)var s=c(r)}for(o&&o(f);u<n.length;u++)r.o(e,d=n[u])&&e[d]&&e[d][0](),e[n[u]]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(i.bind(null,0)),t.push=i.bind(null,t.push.bind(t))})()})();
|
(()=>{"use strict";var e,v={},m={};function r(e){var i=m[e];if(void 0!==i)return i.exports;var t=m[e]={exports:{}};return v[e].call(t.exports,t,t.exports,r),t.exports}r.m=v,e=[],r.O=(i,t,o,f)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,o,f]=e[n],c=!0,l=0;l<t.length;l++)(!1&f||a>=f)&&Object.keys(r.O).every(b=>r.O[b](t[l]))?t.splice(l--,1):(c=!1,f<a&&(a=f));if(c){e.splice(n--,1);var d=o();void 0!==d&&(i=d)}}return i}f=f||0;for(var n=e.length;n>0&&e[n-1][2]>f;n--)e[n]=e[n-1];e[n]=[t,o,f]},r.n=e=>{var i=e&&e.__esModule?()=>e.default:()=>e;return r.d(i,{a:i}),i},r.d=(e,i)=>{for(var t in i)r.o(i,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:i[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((i,t)=>(r.f[t](e,i),i),[])),r.u=e=>(592===e?"common":e)+"."+{66:"97582e026891bf70",103:"5b5d2a6e5a8a7479",146:"92e3b29c4c754544",592:"858f777e9296e6f2",622:"03823b1714105423",853:"84ee7e1d7cff8913"}[e]+".js",r.miniCssF=e=>{},r.o=(e,i)=>Object.prototype.hasOwnProperty.call(e,i),(()=>{var e={},i="blrec:";r.l=(t,o,f,n)=>{if(e[t])e[t].push(o);else{var a,c;if(void 0!==f)for(var l=document.getElementsByTagName("script"),d=0;d<l.length;d++){var u=l[d];if(u.getAttribute("src")==t||u.getAttribute("data-webpack")==i+f){a=u;break}}a||(c=!0,(a=document.createElement("script")).type="module",a.charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",i+f),a.src=r.tu(t)),e[t]=[o];var s=(g,b)=>{a.onerror=a.onload=null,clearTimeout(p);var _=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),_&&_.forEach(h=>h(b)),g)return g(b)},p=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),c&&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=i=>(void 0===e&&(e={createScriptURL:t=>t},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e.createScriptURL(i))})(),r.p="",(()=>{var e={666:0};r.f.j=(o,f)=>{var n=r.o(e,o)?e[o]:void 0;if(0!==n)if(n)f.push(n[2]);else if(666!=o){var a=new Promise((u,s)=>n=e[o]=[u,s]);f.push(n[2]=a);var c=r.p+r.u(o),l=new Error;r.l(c,u=>{if(r.o(e,o)&&(0!==(n=e[o])&&(e[o]=void 0),n)){var s=u&&("load"===u.type?"missing":u.type),p=u&&u.target&&u.target.src;l.message="Loading chunk "+o+" failed.\n("+s+": "+p+")",l.name="ChunkLoadError",l.type=s,l.request=p,n[1](l)}},"chunk-"+o,o)}else e[o]=0},r.O.j=o=>0===e[o];var i=(o,f)=>{var l,d,[n,a,c]=f,u=0;if(n.some(p=>0!==e[p])){for(l in a)r.o(a,l)&&(r.m[l]=a[l]);if(c)var s=c(r)}for(o&&o(f);u<n.length;u++)r.o(e,d=n[u])&&e[d]&&e[d][0](),e[n[u]]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(i.bind(null,0)),t.push=i.bind(null,t.push.bind(t))})()})();
|
@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class SpaceReclaimer(SpaceEventListener, SwitchableMixin):
|
class SpaceReclaimer(SpaceEventListener, SwitchableMixin):
|
||||||
_SUFFIX_SET = frozenset(('.flv', '.mp4', '.xml', '.meta'))
|
_SUFFIX_SET = frozenset(('.flv', '.mp4', '.xml', '.jsonl', '.jpg'))
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@ -9,10 +9,28 @@ from .models import (
|
|||||||
LiveEndedEventData,
|
LiveEndedEventData,
|
||||||
RoomChangeEvent,
|
RoomChangeEvent,
|
||||||
RoomChangeEventData,
|
RoomChangeEventData,
|
||||||
|
RecordingStartedEvent,
|
||||||
|
RecordingStartedEventData,
|
||||||
|
RecordingFinishedEvent,
|
||||||
|
RecordingFinishedEventData,
|
||||||
|
RecordingCancelledEvent,
|
||||||
|
RecordingCancelledEventData,
|
||||||
|
VideoFileCreatedEvent,
|
||||||
|
VideoFileCreatedEventData,
|
||||||
|
VideoFileCompletedEvent,
|
||||||
|
VideoFileCompletedEventData,
|
||||||
|
DanmakuFileCreatedEvent,
|
||||||
|
DanmakuFileCreatedEventData,
|
||||||
|
DanmakuFileCompletedEvent,
|
||||||
|
DanmakuFileCompletedEventData,
|
||||||
|
RawDanmakuFileCreatedEvent,
|
||||||
|
RawDanmakuFileCreatedEventData,
|
||||||
|
RawDanmakuFileCompletedEvent,
|
||||||
|
RawDanmakuFileCompletedEventData,
|
||||||
|
VideoPostprocessingCompletedEvent,
|
||||||
|
VideoPostprocessingCompletedEventData,
|
||||||
SpaceNoEnoughEvent,
|
SpaceNoEnoughEvent,
|
||||||
SpaceNoEnoughEventData,
|
SpaceNoEnoughEventData,
|
||||||
FileCompletedEvent,
|
|
||||||
FileCompletedEventData,
|
|
||||||
Error,
|
Error,
|
||||||
ErrorData,
|
ErrorData,
|
||||||
)
|
)
|
||||||
@ -31,10 +49,28 @@ __all__ = (
|
|||||||
'LiveEndedEventData',
|
'LiveEndedEventData',
|
||||||
'RoomChangeEvent',
|
'RoomChangeEvent',
|
||||||
'RoomChangeEventData',
|
'RoomChangeEventData',
|
||||||
|
'RecordingStartedEvent',
|
||||||
|
'RecordingStartedEventData',
|
||||||
|
'RecordingFinishedEvent',
|
||||||
|
'RecordingFinishedEventData',
|
||||||
|
'RecordingCancelledEvent',
|
||||||
|
'RecordingCancelledEventData',
|
||||||
|
'VideoFileCreatedEvent',
|
||||||
|
'VideoFileCreatedEventData',
|
||||||
|
'VideoFileCompletedEvent',
|
||||||
|
'VideoFileCompletedEventData',
|
||||||
|
'DanmakuFileCreatedEvent',
|
||||||
|
'DanmakuFileCreatedEventData',
|
||||||
|
'DanmakuFileCompletedEvent',
|
||||||
|
'DanmakuFileCompletedEventData',
|
||||||
|
'RawDanmakuFileCreatedEvent',
|
||||||
|
'RawDanmakuFileCreatedEventData',
|
||||||
|
'RawDanmakuFileCompletedEvent',
|
||||||
|
'RawDanmakuFileCompletedEventData',
|
||||||
|
'VideoPostprocessingCompletedEvent',
|
||||||
|
'VideoPostprocessingCompletedEventData',
|
||||||
'SpaceNoEnoughEvent',
|
'SpaceNoEnoughEvent',
|
||||||
'SpaceNoEnoughEventData',
|
'SpaceNoEnoughEventData',
|
||||||
'FileCompletedEvent',
|
|
||||||
'FileCompletedEventData',
|
|
||||||
'Error',
|
'Error',
|
||||||
'ErrorData',
|
'ErrorData',
|
||||||
)
|
)
|
||||||
|
@ -9,18 +9,38 @@ from .models import (
|
|||||||
LiveEndedEventData,
|
LiveEndedEventData,
|
||||||
RoomChangeEvent,
|
RoomChangeEvent,
|
||||||
RoomChangeEventData,
|
RoomChangeEventData,
|
||||||
FileCompletedEvent,
|
RecordingStartedEvent,
|
||||||
FileCompletedEventData,
|
RecordingStartedEventData,
|
||||||
|
RecordingFinishedEvent,
|
||||||
|
RecordingFinishedEventData,
|
||||||
|
RecordingCancelledEvent,
|
||||||
|
RecordingCancelledEventData,
|
||||||
|
VideoFileCreatedEvent,
|
||||||
|
VideoFileCreatedEventData,
|
||||||
|
VideoFileCompletedEvent,
|
||||||
|
VideoFileCompletedEventData,
|
||||||
|
DanmakuFileCreatedEvent,
|
||||||
|
DanmakuFileCreatedEventData,
|
||||||
|
DanmakuFileCompletedEvent,
|
||||||
|
DanmakuFileCompletedEventData,
|
||||||
|
RawDanmakuFileCreatedEvent,
|
||||||
|
RawDanmakuFileCreatedEventData,
|
||||||
|
RawDanmakuFileCompletedEvent,
|
||||||
|
RawDanmakuFileCompletedEventData,
|
||||||
|
VideoPostprocessingCompletedEvent,
|
||||||
|
VideoPostprocessingCompletedEventData,
|
||||||
SpaceNoEnoughEvent,
|
SpaceNoEnoughEvent,
|
||||||
SpaceNoEnoughEventData,
|
SpaceNoEnoughEventData,
|
||||||
)
|
)
|
||||||
from ..bili.live import Live
|
from ..bili.live import Live
|
||||||
from ..bili.models import RoomInfo
|
from ..bili.models import RoomInfo
|
||||||
from ..bili.live_monitor import LiveEventListener
|
from ..bili.live_monitor import LiveEventListener
|
||||||
|
from ..core.recorder import RecorderEventListener
|
||||||
from ..disk_space import SpaceEventListener
|
from ..disk_space import SpaceEventListener
|
||||||
from ..postprocess import PostprocessorEventListener
|
from ..postprocess import PostprocessorEventListener
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..bili.live_monitor import LiveMonitor
|
from ..bili.live_monitor import LiveMonitor
|
||||||
|
from ..core.recorder import Recorder
|
||||||
from ..disk_space import SpaceMonitor, DiskUsage
|
from ..disk_space import SpaceMonitor, DiskUsage
|
||||||
from ..postprocess import Postprocessor
|
from ..postprocess import Postprocessor
|
||||||
|
|
||||||
@ -52,6 +72,74 @@ class LiveEventSubmitter(LiveEventListener):
|
|||||||
event_center.submit(RoomChangeEvent.from_data(data))
|
event_center.submit(RoomChangeEvent.from_data(data))
|
||||||
|
|
||||||
|
|
||||||
|
class RecorderEventSubmitter(RecorderEventListener):
|
||||||
|
def __init__(self, recorder: Recorder) -> None:
|
||||||
|
super().__init__()
|
||||||
|
recorder.add_listener(self)
|
||||||
|
|
||||||
|
async def on_recording_started(self, recorder: Recorder) -> None:
|
||||||
|
data = RecordingStartedEventData(recorder.live.room_info)
|
||||||
|
event_center.submit(RecordingStartedEvent.from_data(data))
|
||||||
|
|
||||||
|
async def on_recording_finished(self, recorder: Recorder) -> None:
|
||||||
|
data = RecordingFinishedEventData(recorder.live.room_info)
|
||||||
|
event_center.submit(RecordingFinishedEvent.from_data(data))
|
||||||
|
|
||||||
|
async def on_recording_cancelled(self, recorder: Recorder) -> None:
|
||||||
|
data = RecordingCancelledEventData(recorder.live.room_info)
|
||||||
|
event_center.submit(RecordingCancelledEvent.from_data(data))
|
||||||
|
|
||||||
|
async def on_video_file_created(
|
||||||
|
self, recorder: Recorder, path: str
|
||||||
|
) -> None:
|
||||||
|
data = VideoFileCreatedEventData(recorder.live.room_id, path)
|
||||||
|
event_center.submit(VideoFileCreatedEvent.from_data(data))
|
||||||
|
|
||||||
|
async def on_video_file_completed(
|
||||||
|
self, recorder: Recorder, path: str
|
||||||
|
) -> None:
|
||||||
|
data = VideoFileCompletedEventData(recorder.live.room_id, path)
|
||||||
|
event_center.submit(VideoFileCompletedEvent.from_data(data))
|
||||||
|
|
||||||
|
async def on_danmaku_file_created(
|
||||||
|
self, recorder: Recorder, path: str
|
||||||
|
) -> None:
|
||||||
|
data = DanmakuFileCreatedEventData(recorder.live.room_id, path)
|
||||||
|
event_center.submit(DanmakuFileCreatedEvent.from_data(data))
|
||||||
|
|
||||||
|
async def on_danmaku_file_completed(
|
||||||
|
self, recorder: Recorder, path: str
|
||||||
|
) -> None:
|
||||||
|
data = DanmakuFileCompletedEventData(recorder.live.room_id, path)
|
||||||
|
event_center.submit(DanmakuFileCompletedEvent.from_data(data))
|
||||||
|
|
||||||
|
async def on_raw_danmaku_file_created(
|
||||||
|
self, recorder: Recorder, path: str
|
||||||
|
) -> None:
|
||||||
|
data = RawDanmakuFileCreatedEventData(recorder.live.room_id, path)
|
||||||
|
event_center.submit(RawDanmakuFileCreatedEvent.from_data(data))
|
||||||
|
|
||||||
|
async def on_raw_danmaku_file_completed(
|
||||||
|
self, recorder: Recorder, path: str
|
||||||
|
) -> None:
|
||||||
|
data = RawDanmakuFileCompletedEventData(recorder.live.room_id, path)
|
||||||
|
event_center.submit(RawDanmakuFileCompletedEvent.from_data(data))
|
||||||
|
|
||||||
|
|
||||||
|
class PostprocessorEventSubmitter(PostprocessorEventListener):
|
||||||
|
def __init__(self, postprocessor: Postprocessor) -> None:
|
||||||
|
super().__init__()
|
||||||
|
postprocessor.add_listener(self)
|
||||||
|
|
||||||
|
async def on_video_postprocessing_completed(
|
||||||
|
self, postprocessor: Postprocessor, path: str
|
||||||
|
) -> None:
|
||||||
|
data = VideoPostprocessingCompletedEventData(
|
||||||
|
postprocessor.recorder.live.room_id, path
|
||||||
|
)
|
||||||
|
event_center.submit(VideoPostprocessingCompletedEvent.from_data(data))
|
||||||
|
|
||||||
|
|
||||||
class SpaceEventSubmitter(SpaceEventListener):
|
class SpaceEventSubmitter(SpaceEventListener):
|
||||||
def __init__(self, space_monitor: SpaceMonitor) -> None:
|
def __init__(self, space_monitor: SpaceMonitor) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@ -62,13 +150,3 @@ class SpaceEventSubmitter(SpaceEventListener):
|
|||||||
) -> None:
|
) -> None:
|
||||||
data = SpaceNoEnoughEventData(path, threshold, disk_usage)
|
data = SpaceNoEnoughEventData(path, threshold, disk_usage)
|
||||||
event_center.submit(SpaceNoEnoughEvent.from_data(data))
|
event_center.submit(SpaceNoEnoughEvent.from_data(data))
|
||||||
|
|
||||||
|
|
||||||
class PostprocessorEventSubmitter(PostprocessorEventListener):
|
|
||||||
def __init__(self, postprocessor: Postprocessor) -> None:
|
|
||||||
super().__init__()
|
|
||||||
postprocessor.add_listener(self)
|
|
||||||
|
|
||||||
async def on_file_completed(self, room_id: int, path: str) -> None:
|
|
||||||
data = FileCompletedEventData(room_id, path)
|
|
||||||
event_center.submit(FileCompletedEvent.from_data(data))
|
|
||||||
|
@ -9,6 +9,7 @@ import attr
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..bili.models import UserInfo, RoomInfo
|
from ..bili.models import UserInfo, RoomInfo
|
||||||
from ..disk_space.space_monitor import DiskUsage
|
from ..disk_space.space_monitor import DiskUsage
|
||||||
|
from ..exception import format_exception
|
||||||
|
|
||||||
|
|
||||||
_D = TypeVar('_D', bound='BaseEventData')
|
_D = TypeVar('_D', bound='BaseEventData')
|
||||||
@ -80,6 +81,127 @@ class RoomChangeEvent(BaseEvent[RoomChangeEventData]):
|
|||||||
data: RoomChangeEventData
|
data: RoomChangeEventData
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||||
|
class RecordingStartedEventData(BaseEventData):
|
||||||
|
room_info: RoomInfo
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||||
|
class RecordingStartedEvent(BaseEvent[RecordingStartedEventData]):
|
||||||
|
type: str = 'RecordingStartedEvent'
|
||||||
|
data: RecordingStartedEventData
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||||
|
class RecordingFinishedEventData(BaseEventData):
|
||||||
|
room_info: RoomInfo
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||||
|
class RecordingFinishedEvent(BaseEvent[RecordingFinishedEventData]):
|
||||||
|
type: str = 'RecordingFinishedEvent'
|
||||||
|
data: RecordingFinishedEventData
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||||
|
class RecordingCancelledEventData(BaseEventData):
|
||||||
|
room_info: RoomInfo
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||||
|
class RecordingCancelledEvent(BaseEvent[RecordingCancelledEventData]):
|
||||||
|
type: str = 'RecordingCancelledEvent'
|
||||||
|
data: RecordingCancelledEventData
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||||
|
class VideoFileCreatedEventData(BaseEventData):
|
||||||
|
room_id: int
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||||
|
class VideoFileCreatedEvent(BaseEvent[VideoFileCreatedEventData]):
|
||||||
|
type: str = 'VideoFileCreatedEvent'
|
||||||
|
data: VideoFileCreatedEventData
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||||
|
class VideoFileCompletedEventData(BaseEventData):
|
||||||
|
room_id: int
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||||
|
class VideoFileCompletedEvent(BaseEvent[VideoFileCompletedEventData]):
|
||||||
|
type: str = 'VideoFileCompletedEvent'
|
||||||
|
data: VideoFileCompletedEventData
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||||
|
class DanmakuFileCreatedEventData(BaseEventData):
|
||||||
|
room_id: int
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||||
|
class DanmakuFileCreatedEvent(BaseEvent[DanmakuFileCreatedEventData]):
|
||||||
|
type: str = 'DanmakuFileCreatedEvent'
|
||||||
|
data: DanmakuFileCreatedEventData
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||||
|
class DanmakuFileCompletedEventData(BaseEventData):
|
||||||
|
room_id: int
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||||
|
class DanmakuFileCompletedEvent(BaseEvent[DanmakuFileCompletedEventData]):
|
||||||
|
type: str = 'DanmakuFileCompletedEvent'
|
||||||
|
data: DanmakuFileCompletedEventData
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||||
|
class RawDanmakuFileCreatedEventData(BaseEventData):
|
||||||
|
room_id: int
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||||
|
class RawDanmakuFileCreatedEvent(BaseEvent[RawDanmakuFileCreatedEventData]):
|
||||||
|
type: str = 'RawDanmakuFileCreatedEvent'
|
||||||
|
data: RawDanmakuFileCreatedEventData
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||||
|
class RawDanmakuFileCompletedEventData(BaseEventData):
|
||||||
|
room_id: int
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||||
|
class RawDanmakuFileCompletedEvent(
|
||||||
|
BaseEvent[RawDanmakuFileCompletedEventData]
|
||||||
|
):
|
||||||
|
type: str = 'RawDanmakuFileCompletedEvent'
|
||||||
|
data: RawDanmakuFileCompletedEventData
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||||
|
class VideoPostprocessingCompletedEventData(BaseEventData):
|
||||||
|
room_id: int
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||||
|
class VideoPostprocessingCompletedEvent(
|
||||||
|
BaseEvent[VideoPostprocessingCompletedEventData]
|
||||||
|
):
|
||||||
|
type: str = 'VideoPostprocessingCompletedEvent'
|
||||||
|
data: VideoPostprocessingCompletedEventData
|
||||||
|
|
||||||
|
|
||||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||||
class SpaceNoEnoughEventData(BaseEventData):
|
class SpaceNoEnoughEventData(BaseEventData):
|
||||||
path: str
|
path: str
|
||||||
@ -93,23 +215,17 @@ class SpaceNoEnoughEvent(BaseEvent[SpaceNoEnoughEventData]):
|
|||||||
data: SpaceNoEnoughEventData
|
data: SpaceNoEnoughEventData
|
||||||
|
|
||||||
|
|
||||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
|
||||||
class FileCompletedEventData(BaseEventData):
|
|
||||||
room_id: int
|
|
||||||
path: str
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
|
||||||
class FileCompletedEvent(BaseEvent[FileCompletedEventData]):
|
|
||||||
type: str = 'FileCompletedEvent'
|
|
||||||
data: FileCompletedEventData
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||||
class ErrorData(BaseEventData):
|
class ErrorData(BaseEventData):
|
||||||
name: str
|
name: str
|
||||||
detail: str
|
detail: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_exc(cls, exc: BaseException) -> ErrorData:
|
||||||
|
return cls(
|
||||||
|
name=type(exc).__name__, detail=format_exception(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||||
class Error(BaseEvent[ErrorData]):
|
class Error(BaseEvent[ErrorData]):
|
||||||
|
@ -8,10 +8,28 @@ from .models import (
|
|||||||
LiveEndedEventData,
|
LiveEndedEventData,
|
||||||
RoomChangeEvent,
|
RoomChangeEvent,
|
||||||
RoomChangeEventData,
|
RoomChangeEventData,
|
||||||
|
RecordingStartedEvent,
|
||||||
|
RecordingStartedEventData,
|
||||||
|
RecordingFinishedEvent,
|
||||||
|
RecordingFinishedEventData,
|
||||||
|
RecordingCancelledEvent,
|
||||||
|
RecordingCancelledEventData,
|
||||||
|
VideoFileCreatedEvent,
|
||||||
|
VideoFileCreatedEventData,
|
||||||
|
VideoFileCompletedEvent,
|
||||||
|
VideoFileCompletedEventData,
|
||||||
|
DanmakuFileCreatedEvent,
|
||||||
|
DanmakuFileCreatedEventData,
|
||||||
|
DanmakuFileCompletedEvent,
|
||||||
|
DanmakuFileCompletedEventData,
|
||||||
|
RawDanmakuFileCreatedEvent,
|
||||||
|
RawDanmakuFileCreatedEventData,
|
||||||
|
RawDanmakuFileCompletedEvent,
|
||||||
|
RawDanmakuFileCompletedEventData,
|
||||||
|
VideoPostprocessingCompletedEvent,
|
||||||
|
VideoPostprocessingCompletedEventData,
|
||||||
SpaceNoEnoughEvent,
|
SpaceNoEnoughEvent,
|
||||||
SpaceNoEnoughEventData,
|
SpaceNoEnoughEventData,
|
||||||
FileCompletedEvent,
|
|
||||||
FileCompletedEventData,
|
|
||||||
Error,
|
Error,
|
||||||
ErrorData,
|
ErrorData,
|
||||||
)
|
)
|
||||||
@ -21,8 +39,17 @@ Event = Union[
|
|||||||
LiveBeganEvent,
|
LiveBeganEvent,
|
||||||
LiveEndedEvent,
|
LiveEndedEvent,
|
||||||
RoomChangeEvent,
|
RoomChangeEvent,
|
||||||
|
RecordingStartedEvent,
|
||||||
|
RecordingFinishedEvent,
|
||||||
|
RecordingCancelledEvent,
|
||||||
|
VideoFileCreatedEvent,
|
||||||
|
VideoFileCompletedEvent,
|
||||||
|
DanmakuFileCreatedEvent,
|
||||||
|
DanmakuFileCompletedEvent,
|
||||||
|
RawDanmakuFileCreatedEvent,
|
||||||
|
RawDanmakuFileCompletedEvent,
|
||||||
|
VideoPostprocessingCompletedEvent,
|
||||||
SpaceNoEnoughEvent,
|
SpaceNoEnoughEvent,
|
||||||
FileCompletedEvent,
|
|
||||||
Error,
|
Error,
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -30,7 +57,16 @@ EventData = Union[
|
|||||||
LiveBeganEventData,
|
LiveBeganEventData,
|
||||||
LiveEndedEventData,
|
LiveEndedEventData,
|
||||||
RoomChangeEventData,
|
RoomChangeEventData,
|
||||||
|
RecordingStartedEventData,
|
||||||
|
RecordingFinishedEventData,
|
||||||
|
RecordingCancelledEventData,
|
||||||
|
VideoFileCreatedEventData,
|
||||||
|
VideoFileCompletedEventData,
|
||||||
|
DanmakuFileCreatedEventData,
|
||||||
|
DanmakuFileCompletedEventData,
|
||||||
|
RawDanmakuFileCreatedEventData,
|
||||||
|
RawDanmakuFileCompletedEventData,
|
||||||
|
VideoPostprocessingCompletedEventData,
|
||||||
SpaceNoEnoughEventData,
|
SpaceNoEnoughEventData,
|
||||||
FileCompletedEventData,
|
|
||||||
ErrorData,
|
ErrorData,
|
||||||
]
|
]
|
||||||
|
@ -26,5 +26,4 @@ class ExceptionHandler(SwitchableMixin):
|
|||||||
|
|
||||||
def _log_exception(self, exc: BaseException) -> None:
|
def _log_exception(self, exc: BaseException) -> None:
|
||||||
exc_info = (type(exc), exc, exc.__traceback__)
|
exc_info = (type(exc), exc, exc.__traceback__)
|
||||||
msg = 'Unhandled Exception'
|
logger.critical(type(exc).__name__, exc_info=exc_info)
|
||||||
logger.critical(msg, exc_info=exc_info)
|
|
||||||
|
@ -3,6 +3,4 @@ import traceback
|
|||||||
|
|
||||||
def format_exception(exc: BaseException) -> str:
|
def format_exception(exc: BaseException) -> str:
|
||||||
exc_info = (type(exc), exc, exc.__traceback__)
|
exc_info = (type(exc), exc, exc.__traceback__)
|
||||||
errmsg = 'Unhandled exception in event loop\n'
|
return ''.join(traceback.format_exception(*exc_info))
|
||||||
errmsg += ''.join(traceback.format_exception(*exc_info))
|
|
||||||
return errmsg
|
|
||||||
|
@ -3,19 +3,19 @@ class FlvDataError(ValueError):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
class InvalidFlvHeaderError(FlvDataError):
|
class FlvHeaderError(FlvDataError):
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
class InvalidFlvTagError(FlvDataError):
|
class FlvTagError(FlvDataError):
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
class FlvStreamCorruptedError(FlvDataError):
|
class FlvStreamCorruptedError(Exception):
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
class FlvFileCorruptedError(FlvDataError):
|
class FlvFileCorruptedError(Exception):
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,15 +1,19 @@
|
|||||||
|
from io import BytesIO, SEEK_CUR
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
import attr
|
import attr
|
||||||
|
|
||||||
from .struct_io import StructReader, StructWriter
|
from .struct_io import StructReader, StructWriter
|
||||||
from .io_protocols import RandomIO
|
from .io_protocols import RandomIO
|
||||||
from .exceptions import InvalidFlvHeaderError, InvalidFlvTagError
|
from .exceptions import FlvHeaderError, FlvDataError, FlvTagError
|
||||||
from .models import (
|
from .models import (
|
||||||
FlvTag,
|
FlvTag,
|
||||||
TagType,
|
TagType,
|
||||||
FlvHeader,
|
FlvHeader,
|
||||||
FlvTagHeader,
|
FlvTagHeader,
|
||||||
|
TAG_HEADER_SIZE,
|
||||||
|
AUDIO_TAG_HEADER_SIZE,
|
||||||
|
VIDEO_TAG_HEADER_SIZE,
|
||||||
|
|
||||||
AudioTag,
|
AudioTag,
|
||||||
AudioTagHeader,
|
AudioTagHeader,
|
||||||
@ -40,7 +44,7 @@ class FlvParser:
|
|||||||
def parse_header(self) -> FlvHeader:
|
def parse_header(self) -> FlvHeader:
|
||||||
signature = self._reader.read(3).decode()
|
signature = self._reader.read(3).decode()
|
||||||
if signature != 'FLV':
|
if signature != 'FLV':
|
||||||
raise InvalidFlvHeaderError(signature)
|
raise FlvHeaderError(signature)
|
||||||
version = self._reader.read_ui8()
|
version = self._reader.read_ui8()
|
||||||
type_flag = self._reader.read_ui8()
|
type_flag = self._reader.read_ui8()
|
||||||
data_offset = self._reader.read_ui32()
|
data_offset = self._reader.read_ui32()
|
||||||
@ -51,75 +55,98 @@ class FlvParser:
|
|||||||
|
|
||||||
def parse_tag(self, *, no_body: bool = False) -> FlvTag:
|
def parse_tag(self, *, no_body: bool = False) -> FlvTag:
|
||||||
offset = self._stream.tell()
|
offset = self._stream.tell()
|
||||||
tag_header = self.parse_flv_tag_header()
|
tag_header_data = self._reader.read(TAG_HEADER_SIZE)
|
||||||
|
tag_header = self.parse_flv_tag_header(tag_header_data)
|
||||||
|
|
||||||
tag: FlvTag
|
if tag_header.data_size <= 0:
|
||||||
arguments = dict(attr.asdict(tag_header), offset=offset)
|
raise FlvTagError('No tag data', tag_header)
|
||||||
|
|
||||||
if tag_header.tag_type == TagType.AUDIO:
|
if tag_header.tag_type == TagType.AUDIO:
|
||||||
audio_tag_header = self.parse_audio_tag_header()
|
header_data = self._reader.read(AUDIO_TAG_HEADER_SIZE)
|
||||||
arguments.update(attr.asdict(audio_tag_header))
|
body_size = tag_header.data_size - AUDIO_TAG_HEADER_SIZE
|
||||||
tag = AudioTag(**arguments)
|
|
||||||
elif tag_header.tag_type == TagType.VIDEO:
|
|
||||||
video_tag_header = self.parse_video_tag_header()
|
|
||||||
arguments.update(attr.asdict(video_tag_header))
|
|
||||||
tag = VideoTag(**arguments)
|
|
||||||
elif tag_header.tag_type == TagType.SCRIPT:
|
|
||||||
tag = ScriptTag(**arguments)
|
|
||||||
else:
|
|
||||||
raise InvalidFlvTagError(tag_header.tag_type)
|
|
||||||
|
|
||||||
if no_body:
|
if no_body:
|
||||||
self._stream.seek(tag.tag_end_offset)
|
self._stream.seek(body_size, SEEK_CUR)
|
||||||
|
body = None
|
||||||
else:
|
else:
|
||||||
body = self._reader.read(tag.body_size)
|
body = self._reader.read(body_size)
|
||||||
tag = tag.evolve(body=body)
|
audio_tag_header = self.parse_audio_tag_header(header_data)
|
||||||
|
return AudioTag(
|
||||||
|
offset=offset,
|
||||||
|
**attr.asdict(tag_header),
|
||||||
|
**attr.asdict(audio_tag_header),
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
elif tag_header.tag_type == TagType.VIDEO:
|
||||||
|
header_data = self._reader.read(VIDEO_TAG_HEADER_SIZE)
|
||||||
|
body_size = tag_header.data_size - VIDEO_TAG_HEADER_SIZE
|
||||||
|
if no_body:
|
||||||
|
self._stream.seek(body_size, SEEK_CUR)
|
||||||
|
body = None
|
||||||
|
else:
|
||||||
|
body = self._reader.read(body_size)
|
||||||
|
video_tag_header = self.parse_video_tag_header(header_data)
|
||||||
|
return VideoTag(
|
||||||
|
offset=offset,
|
||||||
|
**attr.asdict(tag_header),
|
||||||
|
**attr.asdict(video_tag_header),
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
elif tag_header.tag_type == TagType.SCRIPT:
|
||||||
|
body_size = tag_header.data_size
|
||||||
|
if no_body:
|
||||||
|
self._stream.seek(body_size, SEEK_CUR)
|
||||||
|
body = None
|
||||||
|
else:
|
||||||
|
body = self._reader.read(body_size)
|
||||||
|
return ScriptTag(
|
||||||
|
offset=offset,
|
||||||
|
**attr.asdict(tag_header),
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise FlvDataError(f'Unsupported tag type: {tag_header.tag_type}')
|
||||||
|
|
||||||
return tag
|
def parse_flv_tag_header(self, data: bytes) -> FlvTagHeader:
|
||||||
|
reader = StructReader(BytesIO(data))
|
||||||
def parse_flv_tag_header(self) -> FlvTagHeader:
|
flag = reader.read_ui8()
|
||||||
flag = self._reader.read_ui8()
|
|
||||||
filtered = bool(flag & 0b0010_0000)
|
filtered = bool(flag & 0b0010_0000)
|
||||||
if filtered:
|
if filtered:
|
||||||
raise NotImplementedError('Unsupported Filtered FLV Tag')
|
raise FlvDataError('Unsupported Filtered FLV Tag', data)
|
||||||
tag_type = TagType(flag & 0b0001_1111)
|
tag_type = TagType(flag & 0b0001_1111)
|
||||||
data_size = self._reader.read_ui24()
|
data_size = reader.read_ui24()
|
||||||
timestamp = self._reader.read_ui24()
|
timestamp = reader.read_ui24()
|
||||||
timestamp_extended = self._reader.read_ui8()
|
timestamp_extended = reader.read_ui8()
|
||||||
timestamp = timestamp_extended << 24 | timestamp
|
timestamp = timestamp_extended << 24 | timestamp
|
||||||
stream_id = self._reader.read_ui24()
|
stream_id = reader.read_ui24()
|
||||||
tag_header = FlvTagHeader(
|
return FlvTagHeader(
|
||||||
filtered, tag_type, data_size, timestamp, stream_id
|
filtered, tag_type, data_size, timestamp, stream_id
|
||||||
)
|
)
|
||||||
if data_size <= 0:
|
|
||||||
raise InvalidFlvTagError(tag_header)
|
|
||||||
return tag_header
|
|
||||||
|
|
||||||
def parse_audio_tag_header(self) -> AudioTagHeader:
|
def parse_audio_tag_header(self, data: bytes) -> AudioTagHeader:
|
||||||
flag = self._reader.read_ui8()
|
reader = StructReader(BytesIO(data))
|
||||||
|
flag = reader.read_ui8()
|
||||||
sound_format = SoundFormat(flag >> 4)
|
sound_format = SoundFormat(flag >> 4)
|
||||||
if sound_format != SoundFormat.AAC:
|
if sound_format != SoundFormat.AAC:
|
||||||
raise NotImplementedError(
|
raise FlvDataError(
|
||||||
f'Unsupported sound format: {sound_format}'
|
f'Unsupported sound format: {sound_format}', data
|
||||||
)
|
)
|
||||||
sound_rate = SoundRate((flag >> 2) & 0b0000_0011)
|
sound_rate = SoundRate((flag >> 2) & 0b0000_0011)
|
||||||
sound_size = SoundSize((flag >> 1) & 0b0000_0001)
|
sound_size = SoundSize((flag >> 1) & 0b0000_0001)
|
||||||
sound_type = SoundType(flag & 0b0000_0001)
|
sound_type = SoundType(flag & 0b0000_0001)
|
||||||
aac_packet_type = AACPacketType(self._reader.read_ui8())
|
aac_packet_type = AACPacketType(reader.read_ui8())
|
||||||
return AudioTagHeader(
|
return AudioTagHeader(
|
||||||
sound_format, sound_rate, sound_size, sound_type, aac_packet_type
|
sound_format, sound_rate, sound_size, sound_type, aac_packet_type
|
||||||
)
|
)
|
||||||
|
|
||||||
def parse_video_tag_header(self) -> VideoTagHeader:
|
def parse_video_tag_header(self, data: bytes) -> VideoTagHeader:
|
||||||
flag = self._reader.read_ui8()
|
reader = StructReader(BytesIO(data))
|
||||||
|
flag = reader.read_ui8()
|
||||||
frame_type = FrameType(flag >> 4)
|
frame_type = FrameType(flag >> 4)
|
||||||
codec_id = CodecID(flag & 0b0000_1111)
|
codec_id = CodecID(flag & 0b0000_1111)
|
||||||
if codec_id != CodecID.AVC:
|
if codec_id != CodecID.AVC:
|
||||||
raise NotImplementedError(
|
raise FlvDataError(f'Unsupported video codec: {codec_id}', data)
|
||||||
f'Unsupported video codec: {codec_id}'
|
avc_packet_type = AVCPacketType(reader.read_ui8())
|
||||||
)
|
composition_time = reader.read_ui24()
|
||||||
avc_packet_type = AVCPacketType(self._reader.read_ui8())
|
|
||||||
composition_time = self._reader.read_ui24()
|
|
||||||
return VideoTagHeader(
|
return VideoTagHeader(
|
||||||
frame_type, codec_id, avc_packet_type, composition_time
|
frame_type, codec_id, avc_packet_type, composition_time
|
||||||
)
|
)
|
||||||
@ -151,7 +178,7 @@ class FlvDumper:
|
|||||||
elif tag.is_script_tag():
|
elif tag.is_script_tag():
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
raise InvalidFlvTagError(tag.tag_type)
|
raise FlvDataError(f'Unsupported tag type: {tag.tag_type}')
|
||||||
|
|
||||||
if tag.body is None:
|
if tag.body is None:
|
||||||
self._stream.seek(tag.tag_end_offset)
|
self._stream.seek(tag.tag_end_offset)
|
||||||
@ -167,8 +194,8 @@ class FlvDumper:
|
|||||||
|
|
||||||
def dump_audio_tag_header(self, tag: AudioTag) -> None:
|
def dump_audio_tag_header(self, tag: AudioTag) -> None:
|
||||||
if tag.sound_format != SoundFormat.AAC:
|
if tag.sound_format != SoundFormat.AAC:
|
||||||
raise NotImplementedError(
|
raise FlvDataError(
|
||||||
f'Unsupported sound format: {tag.sound_format}'
|
f'Unsupported sound format: {tag.sound_format}', tag
|
||||||
)
|
)
|
||||||
self._writer.write_ui8(
|
self._writer.write_ui8(
|
||||||
(tag.sound_format.value << 4) |
|
(tag.sound_format.value << 4) |
|
||||||
@ -180,9 +207,7 @@ class FlvDumper:
|
|||||||
|
|
||||||
def dump_video_tag_header(self, tag: VideoTag) -> None:
|
def dump_video_tag_header(self, tag: VideoTag) -> None:
|
||||||
if tag.codec_id != CodecID.AVC:
|
if tag.codec_id != CodecID.AVC:
|
||||||
raise NotImplementedError(
|
raise FlvDataError(f'Unsupported video codec: {tag.codec_id}', tag)
|
||||||
f'Unsupported video codec: {tag.codec_id}'
|
|
||||||
)
|
|
||||||
self._writer.write_ui8(
|
self._writer.write_ui8(
|
||||||
(tag.frame_type.value << 4) | tag.codec_id.value
|
(tag.frame_type.value << 4) | tag.codec_id.value
|
||||||
)
|
)
|
||||||
|
@ -137,8 +137,10 @@ class VideoTagHeader:
|
|||||||
composition_time: Optional[int]
|
composition_time: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
TAG_HEADER_SIZE: Final[int] = 11
|
|
||||||
BACK_POINTER_SIZE: Final[int] = 4
|
BACK_POINTER_SIZE: Final[int] = 4
|
||||||
|
TAG_HEADER_SIZE: Final[int] = 11
|
||||||
|
AUDIO_TAG_HEADER_SIZE: Final[int] = 2
|
||||||
|
VIDEO_TAG_HEADER_SIZE: Final[int] = 5
|
||||||
|
|
||||||
|
|
||||||
_T = TypeVar('_T', bound='FlvTag')
|
_T = TypeVar('_T', bound='FlvTag')
|
||||||
|
@ -22,7 +22,7 @@ from .io import FlvReader, FlvWriter
|
|||||||
from .io_protocols import RandomIO
|
from .io_protocols import RandomIO
|
||||||
from .utils import format_offest, format_timestamp
|
from .utils import format_offest, format_timestamp
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
FlvDataError,
|
FlvTagError,
|
||||||
FlvStreamCorruptedError,
|
FlvStreamCorruptedError,
|
||||||
AudioParametersChanged,
|
AudioParametersChanged,
|
||||||
VideoParametersChanged,
|
VideoParametersChanged,
|
||||||
@ -32,8 +32,8 @@ from .exceptions import (
|
|||||||
)
|
)
|
||||||
from .common import (
|
from .common import (
|
||||||
is_audio_tag, is_metadata_tag, is_video_tag, parse_metadata,
|
is_audio_tag, is_metadata_tag, is_video_tag, parse_metadata,
|
||||||
|
is_audio_data_tag, is_video_data_tag, is_sequence_header,
|
||||||
enrich_metadata, update_metadata, is_data_tag, read_tags_in_duration,
|
enrich_metadata, update_metadata, is_data_tag, read_tags_in_duration,
|
||||||
is_sequence_header
|
|
||||||
)
|
)
|
||||||
from ..path import extra_metadata_path
|
from ..path import extra_metadata_path
|
||||||
|
|
||||||
@ -71,6 +71,7 @@ class StreamProcessor:
|
|||||||
self._data_analyser = DataAnalyser()
|
self._data_analyser = DataAnalyser()
|
||||||
|
|
||||||
self._metadata = metadata.copy() if metadata else {}
|
self._metadata = metadata.copy() if metadata else {}
|
||||||
|
self._metadata_tag: ScriptTag
|
||||||
|
|
||||||
self._disable_limit = disable_limit
|
self._disable_limit = disable_limit
|
||||||
self._analyse_data = analyse_data
|
self._analyse_data = analyse_data
|
||||||
@ -85,9 +86,9 @@ class StreamProcessor:
|
|||||||
|
|
||||||
self._delta: int = 0
|
self._delta: int = 0
|
||||||
self._has_audio: bool = False
|
self._has_audio: bool = False
|
||||||
self._metadata_tag: ScriptTag
|
|
||||||
self._last_tags: List[FlvTag] = []
|
self._last_tags: List[FlvTag] = []
|
||||||
self._join_points: List[JoinPoint] = []
|
self._join_points: List[JoinPoint] = []
|
||||||
|
self._resetting_file: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filesize_limit(self) -> int:
|
def filesize_limit(self) -> int:
|
||||||
@ -162,16 +163,29 @@ class StreamProcessor:
|
|||||||
def _need_to_finalize(self) -> bool:
|
def _need_to_finalize(self) -> bool:
|
||||||
return self._stream_count > 0 and len(self._last_tags) > 0
|
return self._stream_count > 0 and len(self._last_tags) > 0
|
||||||
|
|
||||||
def _new_file(self) -> None:
|
def _reset_params(self) -> None:
|
||||||
|
self._delta = 0
|
||||||
|
self._has_audio = False
|
||||||
|
self._last_tags = []
|
||||||
|
self._join_points = []
|
||||||
|
self._resetting_file = False
|
||||||
|
|
||||||
self._stream_cutter.reset()
|
self._stream_cutter.reset()
|
||||||
if not self._disable_limit:
|
if not self._disable_limit:
|
||||||
self._limit_checker.reset()
|
self._limit_checker.reset()
|
||||||
|
if self._analyse_data:
|
||||||
|
self._data_analyser.reset()
|
||||||
|
|
||||||
|
def _new_file(self) -> None:
|
||||||
|
self._reset_params()
|
||||||
self._out_file = self._file_manager.create_file()
|
self._out_file = self._file_manager.create_file()
|
||||||
self._out_reader = FlvReader(self._out_file)
|
|
||||||
self._out_writer = FlvWriter(self._out_file)
|
self._out_writer = FlvWriter(self._out_file)
|
||||||
|
logger.debug(f'New file: {self._file_manager.curr_path}')
|
||||||
|
|
||||||
logger.debug(f'new file: {self._file_manager.curr_path}')
|
def _reset_file(self) -> None:
|
||||||
|
self._reset_params()
|
||||||
|
self._out_file.truncate(0)
|
||||||
|
logger.debug(f'Reset file: {self._file_manager.curr_path}')
|
||||||
|
|
||||||
def _complete_file(self) -> None:
|
def _complete_file(self) -> None:
|
||||||
curr_path = self._file_manager.curr_path
|
curr_path = self._file_manager.curr_path
|
||||||
@ -182,26 +196,7 @@ class StreamProcessor:
|
|||||||
self._update_metadata_tag()
|
self._update_metadata_tag()
|
||||||
self._file_manager.close_file()
|
self._file_manager.close_file()
|
||||||
|
|
||||||
if self._analyse_data:
|
logger.debug(f'Complete file: {curr_path}')
|
||||||
self._data_analyser.reset()
|
|
||||||
|
|
||||||
logger.debug(f'complete file: {curr_path}')
|
|
||||||
|
|
||||||
def _discard_file(self) -> None:
|
|
||||||
curr_path = self._file_manager.curr_path
|
|
||||||
|
|
||||||
self._file_manager.close_file()
|
|
||||||
|
|
||||||
if self._analyse_data:
|
|
||||||
self._data_analyser.reset()
|
|
||||||
|
|
||||||
logger.debug(f'discard file: {curr_path}')
|
|
||||||
|
|
||||||
def _reset(self) -> None:
|
|
||||||
self._discard_file()
|
|
||||||
self._last_tags = []
|
|
||||||
self._stream_count = 0
|
|
||||||
logger.debug('Reset stream processing')
|
|
||||||
|
|
||||||
def _process_stream(self, stream: RandomIO) -> None:
|
def _process_stream(self, stream: RandomIO) -> None:
|
||||||
logger.debug(f'Processing the {self._stream_count}th stream...')
|
logger.debug(f'Processing the {self._stream_count}th stream...')
|
||||||
@ -227,6 +222,9 @@ class StreamProcessor:
|
|||||||
def _process_initial_stream(
|
def _process_initial_stream(
|
||||||
self, flv_header: FlvHeader, first_data_tag: FlvTag
|
self, flv_header: FlvHeader, first_data_tag: FlvTag
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if self._resetting_file:
|
||||||
|
self._reset_file()
|
||||||
|
else:
|
||||||
self._new_file()
|
self._new_file()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -234,7 +232,8 @@ class StreamProcessor:
|
|||||||
self._transfer_meta_tags()
|
self._transfer_meta_tags()
|
||||||
self._transfer_first_data_tag(first_data_tag)
|
self._transfer_first_data_tag(first_data_tag)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._reset()
|
self._last_tags = []
|
||||||
|
self._resetting_file = True
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
del flv_header, first_data_tag
|
del flv_header, first_data_tag
|
||||||
@ -376,8 +375,6 @@ class StreamProcessor:
|
|||||||
except EOFError:
|
except EOFError:
|
||||||
logger.debug('The input stream exhausted')
|
logger.debug('The input stream exhausted')
|
||||||
break
|
break
|
||||||
except FlvDataError as e:
|
|
||||||
raise FlvStreamCorruptedError(repr(e))
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f'Failed to read data, due to: {repr(e)}')
|
logger.debug(f'Failed to read data, due to: {repr(e)}')
|
||||||
raise
|
raise
|
||||||
@ -493,6 +490,8 @@ class StreamProcessor:
|
|||||||
return header
|
return header
|
||||||
|
|
||||||
def _ensure_ts_correct(self, tag: FlvTag) -> None:
|
def _ensure_ts_correct(self, tag: FlvTag) -> None:
|
||||||
|
if not is_audio_data_tag(tag) or not is_video_data_tag(tag):
|
||||||
|
return
|
||||||
if tag.timestamp + self._delta < 0:
|
if tag.timestamp + self._delta < 0:
|
||||||
self._delta = -tag.timestamp
|
self._delta = -tag.timestamp
|
||||||
logger.warning('Incorrect timestamp: {}, new delta: {}'.format(
|
logger.warning('Incorrect timestamp: {}, new delta: {}'.format(
|
||||||
@ -500,9 +499,9 @@ class StreamProcessor:
|
|||||||
))
|
))
|
||||||
|
|
||||||
def _correct_ts(self, tag: FlvTag, delta: int) -> FlvTag:
|
def _correct_ts(self, tag: FlvTag, delta: int) -> FlvTag:
|
||||||
if delta == 0:
|
if delta == 0 and tag.timestamp >= 0:
|
||||||
return tag
|
return tag
|
||||||
return tag.evolve(timestamp=tag.timestamp + delta)
|
return tag.evolve(timestamp=max(0, tag.timestamp + delta))
|
||||||
|
|
||||||
def _calc_delta_duplicated(self, last_duplicated_tag: FlvTag) -> int:
|
def _calc_delta_duplicated(self, last_duplicated_tag: FlvTag) -> int:
|
||||||
return self._last_tags[0].timestamp - last_duplicated_tag.timestamp
|
return self._last_tags[0].timestamp - last_duplicated_tag.timestamp
|
||||||
@ -685,7 +684,24 @@ class BaseOutputFileManager(ABC):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
class FlvReaderWithTimestampFix(FlvReader):
|
class RobustFlvReader(FlvReader):
|
||||||
|
def read_tag(self, *, no_body: bool = False) -> FlvTag:
|
||||||
|
count = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
tag = super().read_tag(no_body=no_body)
|
||||||
|
except FlvTagError as e:
|
||||||
|
logger.warning(f'Invalid tag: {repr(e)}')
|
||||||
|
self._parser.parse_previous_tag_size()
|
||||||
|
count += 1
|
||||||
|
if count > 3:
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
count = 0
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
class FlvReaderWithTimestampFix(RobustFlvReader):
|
||||||
def __init__(self, stream: RandomIO) -> None:
|
def __init__(self, stream: RandomIO) -> None:
|
||||||
super().__init__(stream)
|
super().__init__(stream)
|
||||||
self._last_tag: Optional[FlvTag] = None
|
self._last_tag: Optional[FlvTag] = None
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from pathlib import PurePath
|
from pathlib import PurePath
|
||||||
@ -35,7 +36,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class PostprocessorEventListener(EventListener):
|
class PostprocessorEventListener(EventListener):
|
||||||
async def on_file_completed(self, room_id: int, path: str) -> None:
|
async def on_video_postprocessing_completed(
|
||||||
|
self, postprocessor: Postprocessor, path: str
|
||||||
|
) -> None:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
@ -68,6 +71,10 @@ class Postprocessor(
|
|||||||
self._postprocessing_progress: Optional[Progress] = None
|
self._postprocessing_progress: Optional[Progress] = None
|
||||||
self._completed_files: List[str] = []
|
self._completed_files: List[str] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def recorder(self) -> Recorder:
|
||||||
|
return self._recorder
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self) -> PostprocessorStatus:
|
def status(self) -> PostprocessorStatus:
|
||||||
return self._status
|
return self._status
|
||||||
@ -87,10 +94,14 @@ class Postprocessor(
|
|||||||
# clear completed files of previous recording
|
# clear completed files of previous recording
|
||||||
self._completed_files.clear()
|
self._completed_files.clear()
|
||||||
|
|
||||||
async def on_video_file_completed(self, path: str) -> None:
|
async def on_video_file_completed(
|
||||||
|
self, recorder: Recorder, path: str
|
||||||
|
) -> None:
|
||||||
self._queue.put_nowait(path)
|
self._queue.put_nowait(path)
|
||||||
|
|
||||||
async def on_danmaku_file_completed(self, path: str) -> None:
|
async def on_danmaku_file_completed(
|
||||||
|
self, recorder: Recorder, path: str
|
||||||
|
) -> None:
|
||||||
self._completed_files.append(path)
|
self._completed_files.append(path)
|
||||||
|
|
||||||
async def _do_start(self) -> None:
|
async def _do_start(self) -> None:
|
||||||
@ -143,7 +154,7 @@ class Postprocessor(
|
|||||||
|
|
||||||
self._completed_files.append(result_path)
|
self._completed_files.append(result_path)
|
||||||
await self._emit(
|
await self._emit(
|
||||||
'file_completed', self._live.room_id, result_path
|
'video_postprocessing_completed', self, result_path,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
submit_exception(exc)
|
submit_exception(exc)
|
||||||
|
@ -18,13 +18,10 @@ from pydantic import BaseModel as PydanticBaseModel
|
|||||||
from pydantic import Field, BaseSettings, validator, PrivateAttr, DirectoryPath
|
from pydantic import Field, BaseSettings, validator, PrivateAttr, DirectoryPath
|
||||||
from pydantic.networks import HttpUrl, EmailStr
|
from pydantic.networks import HttpUrl, EmailStr
|
||||||
|
|
||||||
import typer
|
|
||||||
|
|
||||||
from ..bili.typing import QualityNumber
|
from ..bili.typing import QualityNumber
|
||||||
from ..postprocess import DeleteStrategy
|
from ..postprocess import DeleteStrategy
|
||||||
from ..logging.typing import LOG_LEVEL
|
from ..logging.typing import LOG_LEVEL
|
||||||
from ..utils.string import camel_case
|
from ..utils.string import camel_case
|
||||||
from ..path.helpers import file_exists, create_file
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -67,22 +64,8 @@ __all__ = (
|
|||||||
DEFAULT_SETTINGS_PATH: Final[str] = '~/.blrec/settings.toml'
|
DEFAULT_SETTINGS_PATH: Final[str] = '~/.blrec/settings.toml'
|
||||||
|
|
||||||
|
|
||||||
def settings_file_factory() -> str:
|
|
||||||
path = os.path.abspath(os.path.expanduser(DEFAULT_SETTINGS_PATH))
|
|
||||||
if not file_exists(path):
|
|
||||||
create_file(path)
|
|
||||||
typer.secho(
|
|
||||||
f"Created setting file: '{path}'",
|
|
||||||
fg=typer.colors.BRIGHT_MAGENTA,
|
|
||||||
bold=True,
|
|
||||||
)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
class EnvSettings(BaseSettings):
|
class EnvSettings(BaseSettings):
|
||||||
settings_file: Annotated[
|
settings_file: Annotated[str, Field(env='config')] = DEFAULT_SETTINGS_PATH
|
||||||
str, Field(env='config', default_factory=settings_file_factory)
|
|
||||||
]
|
|
||||||
out_dir: Optional[str] = None
|
out_dir: Optional[str] = None
|
||||||
api_key: Annotated[
|
api_key: Annotated[
|
||||||
Optional[str],
|
Optional[str],
|
||||||
@ -98,7 +81,6 @@ _V = TypeVar('_V')
|
|||||||
|
|
||||||
class BaseModel(PydanticBaseModel):
|
class BaseModel(PydanticBaseModel):
|
||||||
class Config:
|
class Config:
|
||||||
extra = 'forbid'
|
|
||||||
validate_assignment = True
|
validate_assignment = True
|
||||||
anystr_strip_whitespace = True
|
anystr_strip_whitespace = True
|
||||||
allow_population_by_field_name = True
|
allow_population_by_field_name = True
|
||||||
@ -133,6 +115,7 @@ class HeaderSettings(HeaderOptions):
|
|||||||
class DanmakuOptions(BaseModel):
|
class DanmakuOptions(BaseModel):
|
||||||
danmu_uname: Optional[bool]
|
danmu_uname: Optional[bool]
|
||||||
record_gift_send: Optional[bool]
|
record_gift_send: Optional[bool]
|
||||||
|
record_free_gifts: Optional[bool]
|
||||||
record_guard_buy: Optional[bool]
|
record_guard_buy: Optional[bool]
|
||||||
record_super_chat: Optional[bool]
|
record_super_chat: Optional[bool]
|
||||||
save_raw_danmaku: Optional[bool]
|
save_raw_danmaku: Optional[bool]
|
||||||
@ -141,6 +124,7 @@ class DanmakuOptions(BaseModel):
|
|||||||
class DanmakuSettings(DanmakuOptions):
|
class DanmakuSettings(DanmakuOptions):
|
||||||
danmu_uname: bool = False
|
danmu_uname: bool = False
|
||||||
record_gift_send: bool = True
|
record_gift_send: bool = True
|
||||||
|
record_free_gifts: bool = True
|
||||||
record_guard_buy: bool = True
|
record_guard_buy: bool = True
|
||||||
record_super_chat: bool = True
|
record_super_chat: bool = True
|
||||||
save_raw_danmaku: bool = False
|
save_raw_danmaku: bool = False
|
||||||
@ -302,13 +286,13 @@ class SpaceSettings(BaseModel):
|
|||||||
|
|
||||||
@validator('check_interval')
|
@validator('check_interval')
|
||||||
def _validate_interval(cls, value: int) -> int:
|
def _validate_interval(cls, value: int) -> int:
|
||||||
allowed_values = frozenset(60 * i for i in (1, 3, 5, 10))
|
allowed_values = frozenset((10, 30, *(60 * i for i in (1, 3, 5, 10))))
|
||||||
cls._validate_with_collection(value, allowed_values)
|
cls._validate_with_collection(value, allowed_values)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@validator('space_threshold')
|
@validator('space_threshold')
|
||||||
def _validate_threshold(cls, value: int) -> int:
|
def _validate_threshold(cls, value: int) -> int:
|
||||||
allowed_values = frozenset(1024 ** 3 * i for i in (1, 3, 5, 10))
|
allowed_values = frozenset(1024 ** 3 * i for i in (1, 3, 5, 10, 20))
|
||||||
cls._validate_with_collection(value, allowed_values)
|
cls._validate_with_collection(value, allowed_values)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@ -375,8 +359,17 @@ class WebHookEventSettings(BaseModel):
|
|||||||
live_began: bool = True
|
live_began: bool = True
|
||||||
live_ended: bool = True
|
live_ended: bool = True
|
||||||
room_change: bool = True
|
room_change: bool = True
|
||||||
|
recording_started: bool = True
|
||||||
|
recording_finished: bool = True
|
||||||
|
recording_cancelled: bool = True
|
||||||
|
video_file_created: bool = True
|
||||||
|
video_file_completed: bool = True
|
||||||
|
danmaku_file_created: bool = True
|
||||||
|
danmaku_file_completed: bool = True
|
||||||
|
raw_danmaku_file_created: bool = True
|
||||||
|
raw_danmaku_file_completed: bool = True
|
||||||
|
video_postprocessing_completed: bool = True
|
||||||
space_no_enough: bool = True
|
space_no_enough: bool = True
|
||||||
file_completed: bool = True
|
|
||||||
error_occurred: bool = True
|
error_occurred: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@ -47,6 +47,7 @@ class TaskParam:
|
|||||||
# DanmakuSettings
|
# DanmakuSettings
|
||||||
danmu_uname: bool
|
danmu_uname: bool
|
||||||
record_gift_send: bool
|
record_gift_send: bool
|
||||||
|
record_free_gifts: bool
|
||||||
record_guard_buy: bool
|
record_guard_buy: bool
|
||||||
record_super_chat: bool
|
record_super_chat: bool
|
||||||
save_raw_danmaku: bool
|
save_raw_danmaku: bool
|
||||||
|
@ -22,7 +22,7 @@ from ..postprocess import Postprocessor, PostprocessorStatus, DeleteStrategy
|
|||||||
from ..postprocess.remuxer import RemuxProgress
|
from ..postprocess.remuxer import RemuxProgress
|
||||||
from ..flv.metadata_injector import InjectProgress
|
from ..flv.metadata_injector import InjectProgress
|
||||||
from ..event.event_submitters import (
|
from ..event.event_submitters import (
|
||||||
LiveEventSubmitter, PostprocessorEventSubmitter
|
LiveEventSubmitter, RecorderEventSubmitter, PostprocessorEventSubmitter
|
||||||
)
|
)
|
||||||
from ..logging.room_id import aio_task_with_room_id
|
from ..logging.room_id import aio_task_with_room_id
|
||||||
|
|
||||||
@ -44,6 +44,7 @@ class RecordTask:
|
|||||||
user_agent: str = '',
|
user_agent: str = '',
|
||||||
danmu_uname: bool = False,
|
danmu_uname: bool = False,
|
||||||
record_gift_send: bool = False,
|
record_gift_send: bool = False,
|
||||||
|
record_free_gifts: bool = False,
|
||||||
record_guard_buy: bool = False,
|
record_guard_buy: bool = False,
|
||||||
record_super_chat: bool = False,
|
record_super_chat: bool = False,
|
||||||
save_cover: bool = False,
|
save_cover: bool = False,
|
||||||
@ -68,6 +69,7 @@ class RecordTask:
|
|||||||
self._user_agent = user_agent
|
self._user_agent = user_agent
|
||||||
self._danmu_uname = danmu_uname
|
self._danmu_uname = danmu_uname
|
||||||
self._record_gift_send = record_gift_send
|
self._record_gift_send = record_gift_send
|
||||||
|
self._record_free_gifts = record_free_gifts
|
||||||
self._record_guard_buy = record_guard_buy
|
self._record_guard_buy = record_guard_buy
|
||||||
self._record_super_chat = record_super_chat
|
self._record_super_chat = record_super_chat
|
||||||
self._save_cover = save_cover
|
self._save_cover = save_cover
|
||||||
@ -239,6 +241,14 @@ class RecordTask:
|
|||||||
def record_gift_send(self, value: bool) -> None:
|
def record_gift_send(self, value: bool) -> None:
|
||||||
self._recorder.record_gift_send = value
|
self._recorder.record_gift_send = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def record_free_gifts(self) -> bool:
|
||||||
|
return self._recorder.record_free_gifts
|
||||||
|
|
||||||
|
@record_free_gifts.setter
|
||||||
|
def record_free_gifts(self, value: bool) -> None:
|
||||||
|
self._recorder.record_free_gifts = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def record_guard_buy(self) -> bool:
|
def record_guard_buy(self) -> bool:
|
||||||
return self._recorder.record_guard_buy
|
return self._recorder.record_guard_buy
|
||||||
@ -442,6 +452,7 @@ class RecordTask:
|
|||||||
self._setup_live_monitor()
|
self._setup_live_monitor()
|
||||||
self._setup_live_event_submitter()
|
self._setup_live_event_submitter()
|
||||||
self._setup_recorder()
|
self._setup_recorder()
|
||||||
|
self._setup_recorder_event_submitter()
|
||||||
self._setup_postprocessor()
|
self._setup_postprocessor()
|
||||||
self._setup_postprocessor_event_submitter()
|
self._setup_postprocessor_event_submitter()
|
||||||
|
|
||||||
@ -468,6 +479,7 @@ class RecordTask:
|
|||||||
disconnection_timeout=self._disconnection_timeout,
|
disconnection_timeout=self._disconnection_timeout,
|
||||||
danmu_uname=self._danmu_uname,
|
danmu_uname=self._danmu_uname,
|
||||||
record_gift_send=self._record_gift_send,
|
record_gift_send=self._record_gift_send,
|
||||||
|
record_free_gifts=self._record_free_gifts,
|
||||||
record_guard_buy=self._record_guard_buy,
|
record_guard_buy=self._record_guard_buy,
|
||||||
record_super_chat=self._record_super_chat,
|
record_super_chat=self._record_super_chat,
|
||||||
save_cover=self._save_cover,
|
save_cover=self._save_cover,
|
||||||
@ -476,9 +488,8 @@ class RecordTask:
|
|||||||
duration_limit=self._duration_limit,
|
duration_limit=self._duration_limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _setup_postprocessor_event_submitter(self) -> None:
|
def _setup_recorder_event_submitter(self) -> None:
|
||||||
self._postprocessor_event_submitter = \
|
self._recorder_event_submitter = RecorderEventSubmitter(self._recorder)
|
||||||
PostprocessorEventSubmitter(self._postprocessor)
|
|
||||||
|
|
||||||
def _setup_postprocessor(self) -> None:
|
def _setup_postprocessor(self) -> None:
|
||||||
self._postprocessor = Postprocessor(
|
self._postprocessor = Postprocessor(
|
||||||
@ -489,8 +500,14 @@ class RecordTask:
|
|||||||
delete_source=self._delete_source,
|
delete_source=self._delete_source,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _setup_postprocessor_event_submitter(self) -> None:
|
||||||
|
self._postprocessor_event_submitter = \
|
||||||
|
PostprocessorEventSubmitter(self._postprocessor)
|
||||||
|
|
||||||
async def _destroy(self) -> None:
|
async def _destroy(self) -> None:
|
||||||
self._destroy_postprocessor_event_submitter()
|
self._destroy_postprocessor_event_submitter()
|
||||||
|
self._destroy_postprocessor()
|
||||||
|
self._destroy_recorder_event_submitter()
|
||||||
self._destroy_recorder()
|
self._destroy_recorder()
|
||||||
self._destroy_live_event_submitter()
|
self._destroy_live_event_submitter()
|
||||||
self._destroy_live_monitor()
|
self._destroy_live_monitor()
|
||||||
@ -508,8 +525,11 @@ class RecordTask:
|
|||||||
def _destroy_recorder(self) -> None:
|
def _destroy_recorder(self) -> None:
|
||||||
del self._recorder
|
del self._recorder
|
||||||
|
|
||||||
def _destroy_postprocessor_event_submitter(self) -> None:
|
def _destroy_recorder_event_submitter(self) -> None:
|
||||||
del self._postprocessor_event_submitter
|
del self._recorder_event_submitter
|
||||||
|
|
||||||
def _destroy_postprocessor(self) -> None:
|
def _destroy_postprocessor(self) -> None:
|
||||||
del self._postprocessor
|
del self._postprocessor
|
||||||
|
|
||||||
|
def _destroy_postprocessor_event_submitter(self) -> None:
|
||||||
|
del self._postprocessor_event_submitter
|
||||||
|
@ -221,6 +221,7 @@ class RecordTaskManager:
|
|||||||
task = self._get_task(room_id)
|
task = self._get_task(room_id)
|
||||||
task.danmu_uname = settings.danmu_uname
|
task.danmu_uname = settings.danmu_uname
|
||||||
task.record_gift_send = settings.record_gift_send
|
task.record_gift_send = settings.record_gift_send
|
||||||
|
task.record_free_gifts = settings.record_free_gifts
|
||||||
task.record_guard_buy = settings.record_guard_buy
|
task.record_guard_buy = settings.record_guard_buy
|
||||||
task.record_super_chat = settings.record_super_chat
|
task.record_super_chat = settings.record_super_chat
|
||||||
task.save_raw_danmaku = settings.save_raw_danmaku
|
task.save_raw_danmaku = settings.save_raw_danmaku
|
||||||
@ -259,6 +260,7 @@ class RecordTaskManager:
|
|||||||
cookie=task.cookie,
|
cookie=task.cookie,
|
||||||
danmu_uname=task.danmu_uname,
|
danmu_uname=task.danmu_uname,
|
||||||
record_gift_send=task.record_gift_send,
|
record_gift_send=task.record_gift_send,
|
||||||
|
record_free_gifts=task.record_free_gifts,
|
||||||
record_guard_buy=task.record_guard_buy,
|
record_guard_buy=task.record_guard_buy,
|
||||||
record_super_chat=task.record_super_chat,
|
record_super_chat=task.record_super_chat,
|
||||||
save_cover=task.save_cover,
|
save_cover=task.save_cover,
|
||||||
|
@ -1,16 +1,15 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from fastapi import FastAPI, status, Request, Depends, Header
|
from fastapi import FastAPI, status, Request, Depends
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.exceptions import HTTPException
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from pkg_resources import resource_filename
|
from pkg_resources import resource_filename
|
||||||
|
|
||||||
|
from . import security
|
||||||
from .routers import (
|
from .routers import (
|
||||||
tasks, settings, application, validation, websockets, update
|
tasks, settings, application, validation, websockets, update
|
||||||
)
|
)
|
||||||
@ -18,33 +17,28 @@ from .schemas import ResponseMessage
|
|||||||
from ..setting import EnvSettings, Settings
|
from ..setting import EnvSettings, Settings
|
||||||
from ..application import Application
|
from ..application import Application
|
||||||
from ..exception import NotFoundError, ExistsError, ForbiddenError
|
from ..exception import NotFoundError, ExistsError, ForbiddenError
|
||||||
|
from ..path.helpers import file_exists, create_file
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
_env_settings = EnvSettings()
|
_env_settings = EnvSettings()
|
||||||
|
_path = os.path.abspath(os.path.expanduser(_env_settings.settings_file))
|
||||||
|
if not file_exists(_path):
|
||||||
|
create_file(_path)
|
||||||
|
_env_settings.settings_file = _path
|
||||||
|
|
||||||
_settings = Settings.load(_env_settings.settings_file)
|
_settings = Settings.load(_env_settings.settings_file)
|
||||||
_settings.update_from_env_settings(_env_settings)
|
_settings.update_from_env_settings(_env_settings)
|
||||||
|
|
||||||
app = Application(_settings)
|
app = Application(_settings)
|
||||||
|
|
||||||
if _env_settings.api_key is None:
|
if _env_settings.api_key is None:
|
||||||
_dependencies = None
|
_dependencies = None
|
||||||
else:
|
else:
|
||||||
async def validate_api_key(
|
security.api_key = _env_settings.api_key
|
||||||
x_api_key: Optional[str] = Header(None)
|
_dependencies = [Depends(security.authenticate)]
|
||||||
) -> None:
|
|
||||||
assert _env_settings.api_key is not None
|
|
||||||
if (
|
|
||||||
x_api_key is None or
|
|
||||||
not secrets.compare_digest(x_api_key, _env_settings.api_key)
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail='API key is missing or invalid',
|
|
||||||
)
|
|
||||||
|
|
||||||
_dependencies = [Depends(validate_api_key)]
|
|
||||||
|
|
||||||
api = FastAPI(
|
api = FastAPI(
|
||||||
title='Bilibili live streaming recorder web API',
|
title='Bilibili live streaming recorder web API',
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
APIRouter,
|
APIRouter,
|
||||||
WebSocket,
|
WebSocket,
|
||||||
WebSocketDisconnect,
|
WebSocketDisconnect,
|
||||||
)
|
)
|
||||||
from websockets import ConnectionClosed # type: ignore
|
from websockets.exceptions import ConnectionClosed
|
||||||
|
|
||||||
from ...event import EventCenter
|
from ...event import EventCenter
|
||||||
from ...event.typing import Event
|
from ...event.typing import Event
|
||||||
@ -33,7 +34,8 @@ async def receive_events(websocket: WebSocket) -> None:
|
|||||||
|
|
||||||
async def send_event(event: Event) -> None:
|
async def send_event(event: Event) -> None:
|
||||||
try:
|
try:
|
||||||
await websocket.send_json(event.asdict())
|
text = json.dumps(event.asdict(), ensure_ascii=False)
|
||||||
|
await websocket.send_text(text)
|
||||||
except (WebSocketDisconnect, ConnectionClosed) as e:
|
except (WebSocketDisconnect, ConnectionClosed) as e:
|
||||||
logger.debug(f'Events websocket closed: {repr(e)}')
|
logger.debug(f'Events websocket closed: {repr(e)}')
|
||||||
subscription.dispose()
|
subscription.dispose()
|
||||||
|
81
src/blrec/web/security.py
Normal file
81
src/blrec/web/security.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
from typing import Optional, Set, Dict
|
||||||
|
|
||||||
|
from fastapi import status, Request, Header
|
||||||
|
from fastapi.exceptions import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
api_key = ''
|
||||||
|
|
||||||
|
MAX_WHITELIST = 100
|
||||||
|
MAX_BLACKLIST = 100
|
||||||
|
MAX_ATTEMPTING_CLIENTS = 100
|
||||||
|
MAX_ATTEMPTS = 3
|
||||||
|
whitelist: Set[str] = set()
|
||||||
|
blacklist: Set[str] = set()
|
||||||
|
attempting_clients: Dict[str, int] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate(
|
||||||
|
request: Request,
|
||||||
|
x_api_key: Optional[str] = Header(None),
|
||||||
|
) -> None:
|
||||||
|
assert api_key, 'api_key is required'
|
||||||
|
|
||||||
|
if not x_api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail='No api key',
|
||||||
|
)
|
||||||
|
|
||||||
|
client_ip = request.client.host
|
||||||
|
assert client_ip, 'client_ip is required'
|
||||||
|
|
||||||
|
if client_ip in blacklist:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail='Blacklisted',
|
||||||
|
)
|
||||||
|
if client_ip not in whitelist:
|
||||||
|
if (
|
||||||
|
len(whitelist) >= MAX_WHITELIST or
|
||||||
|
len(blacklist) >= MAX_BLACKLIST
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail='Max clients allowed in whitelist or blacklist '
|
||||||
|
'will exceeded',
|
||||||
|
)
|
||||||
|
if len(attempting_clients) >= MAX_ATTEMPTING_CLIENTS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail='Max attempting clients allowed exceeded',
|
||||||
|
)
|
||||||
|
if client_ip not in attempting_clients:
|
||||||
|
attempting_clients[client_ip] = 1
|
||||||
|
else:
|
||||||
|
attempting_clients[client_ip] += 1
|
||||||
|
if attempting_clients[client_ip] > MAX_ATTEMPTS:
|
||||||
|
del attempting_clients[client_ip]
|
||||||
|
blacklist.add(client_ip)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail='Max api key attempts exceeded',
|
||||||
|
)
|
||||||
|
|
||||||
|
if not secrets.compare_digest(x_api_key, api_key):
|
||||||
|
if client_ip in whitelist:
|
||||||
|
whitelist.remove(client_ip)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail='API key is invalid',
|
||||||
|
)
|
||||||
|
|
||||||
|
if client_ip in attempting_clients:
|
||||||
|
del attempting_clients[client_ip]
|
||||||
|
if client_ip not in whitelist:
|
||||||
|
whitelist.add(client_ip)
|
@ -9,8 +9,17 @@ from ..event import (
|
|||||||
LiveBeganEvent,
|
LiveBeganEvent,
|
||||||
LiveEndedEvent,
|
LiveEndedEvent,
|
||||||
RoomChangeEvent,
|
RoomChangeEvent,
|
||||||
|
RecordingStartedEvent,
|
||||||
|
RecordingFinishedEvent,
|
||||||
|
RecordingCancelledEvent,
|
||||||
|
VideoFileCreatedEvent,
|
||||||
|
VideoFileCompletedEvent,
|
||||||
|
DanmakuFileCreatedEvent,
|
||||||
|
DanmakuFileCompletedEvent,
|
||||||
|
RawDanmakuFileCreatedEvent,
|
||||||
|
RawDanmakuFileCompletedEvent,
|
||||||
SpaceNoEnoughEvent,
|
SpaceNoEnoughEvent,
|
||||||
FileCompletedEvent,
|
VideoPostprocessingCompletedEvent,
|
||||||
)
|
)
|
||||||
from ..event.typing import Event
|
from ..event.typing import Event
|
||||||
|
|
||||||
@ -37,8 +46,26 @@ class WebHook:
|
|||||||
types.add(LiveEndedEvent)
|
types.add(LiveEndedEvent)
|
||||||
if settings.room_change:
|
if settings.room_change:
|
||||||
types.add(RoomChangeEvent)
|
types.add(RoomChangeEvent)
|
||||||
if settings.file_completed:
|
if settings.recording_started:
|
||||||
types.add(FileCompletedEvent)
|
types.add(RecordingStartedEvent)
|
||||||
|
if settings.recording_finished:
|
||||||
|
types.add(RecordingFinishedEvent)
|
||||||
|
if settings.recording_cancelled:
|
||||||
|
types.add(RecordingCancelledEvent)
|
||||||
|
if settings.video_file_created:
|
||||||
|
types.add(VideoFileCreatedEvent)
|
||||||
|
if settings.video_file_completed:
|
||||||
|
types.add(VideoFileCompletedEvent)
|
||||||
|
if settings.danmaku_file_created:
|
||||||
|
types.add(DanmakuFileCreatedEvent)
|
||||||
|
if settings.danmaku_file_completed:
|
||||||
|
types.add(DanmakuFileCompletedEvent)
|
||||||
|
if settings.raw_danmaku_file_created:
|
||||||
|
types.add(RawDanmakuFileCreatedEvent)
|
||||||
|
if settings.raw_danmaku_file_completed:
|
||||||
|
types.add(RawDanmakuFileCompletedEvent)
|
||||||
|
if settings.video_postprocessing_completed:
|
||||||
|
types.add(VideoPostprocessingCompletedEvent)
|
||||||
if settings.space_no_enough:
|
if settings.space_no_enough:
|
||||||
types.add(SpaceNoEnoughEvent)
|
types.add(SpaceNoEnoughEvent)
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ from tenacity import (
|
|||||||
|
|
||||||
from .models import WebHook
|
from .models import WebHook
|
||||||
from ..utils.mixins import SwitchableMixin
|
from ..utils.mixins import SwitchableMixin
|
||||||
from ..exception import ExceptionCenter, format_exception
|
from ..exception import ExceptionCenter
|
||||||
from ..event import EventCenter, Error, ErrorData
|
from ..event import EventCenter, Error, ErrorData
|
||||||
from ..event.typing import Event
|
from ..event.typing import Event
|
||||||
from .. import __prog__, __version__
|
from .. import __prog__, __version__
|
||||||
@ -57,11 +57,7 @@ class WebHookEmitter(SwitchableMixin):
|
|||||||
self._send_request(url, event.asdict())
|
self._send_request(url, event.asdict())
|
||||||
|
|
||||||
def _send_exception(self, url: str, exc: BaseException) -> None:
|
def _send_exception(self, url: str, exc: BaseException) -> None:
|
||||||
data = ErrorData(
|
payload = Error.from_data(ErrorData.from_exc(exc)).asdict()
|
||||||
name=type(exc).__name__,
|
|
||||||
detail=format_exception(exc),
|
|
||||||
)
|
|
||||||
payload = Error.from_data(data).asdict()
|
|
||||||
self._send_request(url, payload)
|
self._send_request(url, payload)
|
||||||
|
|
||||||
def _send_request(self, url: str, payload: Dict[str, Any]) -> None:
|
def _send_request(self, url: str, payload: Dict[str, Any]) -> None:
|
||||||
|
@ -8,7 +8,7 @@ import {
|
|||||||
} from '@angular/common/http';
|
} from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { AuthService } from '../services/auth.service';
|
import { AuthService } from '../services/auth.service';
|
||||||
import { catchError } from 'rxjs/operators';
|
import { catchError, retry } from 'rxjs/operators';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthInterceptor implements HttpInterceptor {
|
export class AuthInterceptor implements HttpInterceptor {
|
||||||
@ -26,11 +26,15 @@ export class AuthInterceptor implements HttpInterceptor {
|
|||||||
catchError((error: HttpErrorResponse) => {
|
catchError((error: HttpErrorResponse) => {
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
// Unauthorized
|
// Unauthorized
|
||||||
|
if (this.auth.hasApiKey()) {
|
||||||
|
this.auth.removeApiKey();
|
||||||
|
}
|
||||||
const apiKey = window.prompt('API Key:') ?? '';
|
const apiKey = window.prompt('API Key:') ?? '';
|
||||||
this.auth.setApiKey(apiKey);
|
this.auth.setApiKey(apiKey);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
})
|
}),
|
||||||
|
retry(3)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,8 +4,17 @@ export type Event =
|
|||||||
| LiveBeganEvent
|
| LiveBeganEvent
|
||||||
| LiveEndedEvent
|
| LiveEndedEvent
|
||||||
| RoomChangeEvent
|
| RoomChangeEvent
|
||||||
|
| RecordingStartedEvent
|
||||||
|
| RecordingFinishedEvent
|
||||||
|
| RecordingCancelledEvent
|
||||||
|
| VideoFileCreatedEvent
|
||||||
|
| VideoFileCompletedEvent
|
||||||
|
| DanmakuFileCreatedEvent
|
||||||
|
| DanmakuFileCompletedEvent
|
||||||
|
| RawDanmakuFileCreatedEvent
|
||||||
|
| RawDanmakuFileCompletedEvent
|
||||||
| SpaceNoEnoughEvent
|
| SpaceNoEnoughEvent
|
||||||
| FilesAvailableEvent;
|
| VideoPostprocessingCompletedEvent;
|
||||||
|
|
||||||
export interface LiveBeganEvent {
|
export interface LiveBeganEvent {
|
||||||
readonly type: 'LiveBeganEvent';
|
readonly type: 'LiveBeganEvent';
|
||||||
@ -29,6 +38,75 @@ export interface RoomChangeEvent {
|
|||||||
readonly room_info: RoomInfo;
|
readonly room_info: RoomInfo;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
export interface RecordingStartedEvent {
|
||||||
|
readonly type: 'RecordingStartedEvent';
|
||||||
|
readonly data: {
|
||||||
|
readonly room_info: RoomInfo;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface RecordingFinishedEvent {
|
||||||
|
readonly type: 'RecordingFinishedEvent';
|
||||||
|
readonly data: {
|
||||||
|
readonly room_info: RoomInfo;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordingCancelledEvent {
|
||||||
|
readonly type: 'RecordingCancelledEvent';
|
||||||
|
readonly data: {
|
||||||
|
readonly room_info: RoomInfo;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface VideoFileCreatedEvent {
|
||||||
|
readonly type: 'VideoFileCreatedEvent';
|
||||||
|
readonly data: {
|
||||||
|
room_id: number;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface VideoFileCompletedEvent {
|
||||||
|
readonly type: 'VideoFileCompletedEvent';
|
||||||
|
readonly data: {
|
||||||
|
room_id: number;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface DanmakuFileCreatedEvent {
|
||||||
|
readonly type: 'DanmakuFileCreatedEvent';
|
||||||
|
readonly data: {
|
||||||
|
room_id: number;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface DanmakuFileCompletedEvent {
|
||||||
|
readonly type: 'DanmakuFileCompletedEvent';
|
||||||
|
readonly data: {
|
||||||
|
room_id: number;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface RawDanmakuFileCreatedEvent {
|
||||||
|
readonly type: 'RawDanmakuFileCreatedEvent';
|
||||||
|
readonly data: {
|
||||||
|
room_id: number;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface RawDanmakuFileCompletedEvent {
|
||||||
|
readonly type: 'RawDanmakuFileCompletedEvent';
|
||||||
|
readonly data: {
|
||||||
|
room_id: number;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPostprocessingCompletedEvent {
|
||||||
|
readonly type: 'VideoPostprocessingCompletedEvent';
|
||||||
|
readonly data: {
|
||||||
|
room_id: number;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface SpaceNoEnoughEvent {
|
export interface SpaceNoEnoughEvent {
|
||||||
readonly type: 'SpaceNoEnoughEvent';
|
readonly type: 'SpaceNoEnoughEvent';
|
||||||
@ -39,13 +117,6 @@ export interface SpaceNoEnoughEvent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilesAvailableEvent {
|
|
||||||
readonly type: 'FilesAvailableEvent';
|
|
||||||
readonly data: {
|
|
||||||
files: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiskUsage {
|
export interface DiskUsage {
|
||||||
total: number;
|
total: number;
|
||||||
used: number;
|
used: number;
|
||||||
|
@ -9,6 +9,10 @@ const API_KEY_STORAGE_KEY = 'app-api-key';
|
|||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(private storage: StorageService) {}
|
constructor(private storage: StorageService) {}
|
||||||
|
|
||||||
|
hasApiKey(): boolean {
|
||||||
|
return this.storage.hasData(API_KEY_STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
getApiKey(): string {
|
getApiKey(): string {
|
||||||
return this.storage.getData(API_KEY_STORAGE_KEY) ?? '';
|
return this.storage.getData(API_KEY_STORAGE_KEY) ?? '';
|
||||||
}
|
}
|
||||||
@ -16,4 +20,8 @@ export class AuthService {
|
|||||||
setApiKey(value: string): void {
|
setApiKey(value: string): void {
|
||||||
this.storage.setData(API_KEY_STORAGE_KEY, value);
|
this.storage.setData(API_KEY_STORAGE_KEY, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeApiKey() {
|
||||||
|
this.storage.removeData(API_KEY_STORAGE_KEY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,8 @@
|
|||||||
<nz-form-label
|
<nz-form-label
|
||||||
class="setting-label"
|
class="setting-label"
|
||||||
nzNoColon
|
nzNoColon
|
||||||
nzTooltipTitle="记录送礼信息到弹幕文件里"
|
nzTooltipTitle="记录礼物信息到弹幕文件里"
|
||||||
>记录送礼</nz-form-label
|
>记录礼物</nz-form-label
|
||||||
>
|
>
|
||||||
<nz-form-control
|
<nz-form-control
|
||||||
class="setting-control switch"
|
class="setting-control switch"
|
||||||
@ -16,6 +16,23 @@
|
|||||||
<nz-switch formControlName="recordGiftSend"></nz-switch>
|
<nz-switch formControlName="recordGiftSend"></nz-switch>
|
||||||
</nz-form-control>
|
</nz-form-control>
|
||||||
</nz-form-item>
|
</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.recordFreeGifts ? recordFreeGiftsControl : 'warning'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<nz-switch formControlName="recordFreeGifts"></nz-switch>
|
||||||
|
</nz-form-control>
|
||||||
|
</nz-form-item>
|
||||||
<nz-form-item class="setting-item" appSwitchActionable>
|
<nz-form-item class="setting-item" appSwitchActionable>
|
||||||
<nz-form-label
|
<nz-form-label
|
||||||
class="setting-label"
|
class="setting-label"
|
||||||
|
@ -40,6 +40,7 @@ export class DanmakuSettingsComponent implements OnInit, OnChanges {
|
|||||||
this.settingsForm = formBuilder.group({
|
this.settingsForm = formBuilder.group({
|
||||||
danmuUname: [''],
|
danmuUname: [''],
|
||||||
recordGiftSend: [''],
|
recordGiftSend: [''],
|
||||||
|
recordFreeGifts: [''],
|
||||||
recordGuardBuy: [''],
|
recordGuardBuy: [''],
|
||||||
recordSuperChat: [''],
|
recordSuperChat: [''],
|
||||||
saveRawDanmaku: [''],
|
saveRawDanmaku: [''],
|
||||||
@ -54,6 +55,10 @@ export class DanmakuSettingsComponent implements OnInit, OnChanges {
|
|||||||
return this.settingsForm.get('recordGiftSend') as FormControl;
|
return this.settingsForm.get('recordGiftSend') as FormControl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get recordFreeGiftsControl() {
|
||||||
|
return this.settingsForm.get('recordFreeGifts') as FormControl;
|
||||||
|
}
|
||||||
|
|
||||||
get recordGuardBuyControl() {
|
get recordGuardBuyControl() {
|
||||||
return this.settingsForm.get('recordGuardBuy') as FormControl;
|
return this.settingsForm.get('recordGuardBuy') as FormControl;
|
||||||
}
|
}
|
||||||
|
@ -33,6 +33,8 @@ export class DiskSpaceSettingsComponent implements OnInit, OnChanges {
|
|||||||
readonly syncFailedWarningTip = SYNC_FAILED_WARNING_TIP;
|
readonly syncFailedWarningTip = SYNC_FAILED_WARNING_TIP;
|
||||||
|
|
||||||
readonly intervalOptions = [
|
readonly intervalOptions = [
|
||||||
|
{ label: '10 秒', value: 10 },
|
||||||
|
{ label: '30 秒', value: 30 },
|
||||||
{ label: '1 分钟', value: 60 },
|
{ label: '1 分钟', value: 60 },
|
||||||
{ label: '3 分钟', value: 180 },
|
{ label: '3 分钟', value: 180 },
|
||||||
{ label: '5 分钟', value: 300 },
|
{ label: '5 分钟', value: 300 },
|
||||||
@ -44,6 +46,7 @@ export class DiskSpaceSettingsComponent implements OnInit, OnChanges {
|
|||||||
{ label: '3 GB', value: 1024 ** 3 * 3 },
|
{ label: '3 GB', value: 1024 ** 3 * 3 },
|
||||||
{ label: '5 GB', value: 1024 ** 3 * 5 },
|
{ label: '5 GB', value: 1024 ** 3 * 5 },
|
||||||
{ label: '10 GB', value: 1024 ** 3 * 10 },
|
{ label: '10 GB', value: 1024 ** 3 * 10 },
|
||||||
|
{ label: '20 GB', value: 1024 ** 3 * 20 },
|
||||||
];
|
];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -10,6 +10,7 @@ export type HeaderOptions = Nullable<HeaderSettings>;
|
|||||||
export interface DanmakuSettings {
|
export interface DanmakuSettings {
|
||||||
danmuUname: boolean;
|
danmuUname: boolean;
|
||||||
recordGiftSend: boolean;
|
recordGiftSend: boolean;
|
||||||
|
recordFreeGifts: boolean;
|
||||||
recordGuardBuy: boolean;
|
recordGuardBuy: boolean;
|
||||||
recordSuperChat: boolean;
|
recordSuperChat: boolean;
|
||||||
saveRawDanmaku: boolean;
|
saveRawDanmaku: boolean;
|
||||||
@ -164,8 +165,17 @@ export interface WebhookEventSettings {
|
|||||||
liveBegan: boolean;
|
liveBegan: boolean;
|
||||||
liveEnded: boolean;
|
liveEnded: boolean;
|
||||||
roomChange: boolean;
|
roomChange: boolean;
|
||||||
|
recordingStarted: boolean;
|
||||||
|
recordingFinished: boolean;
|
||||||
|
recordingCancelled: boolean;
|
||||||
|
videoFileCreated: boolean;
|
||||||
|
videoFileCompleted: boolean;
|
||||||
|
danmakuFileCreated: boolean;
|
||||||
|
danmakuFileCompleted: boolean;
|
||||||
|
rawDanmakuFileCreated: boolean;
|
||||||
|
rawDanmakuFileCompleted: boolean;
|
||||||
|
videoPostprocessingCompleted: boolean;
|
||||||
spaceNoEnough: boolean;
|
spaceNoEnough: boolean;
|
||||||
fileCompleted: boolean;
|
|
||||||
errorOccurred: boolean;
|
errorOccurred: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,8 +62,71 @@
|
|||||||
</nz-form-item>
|
</nz-form-item>
|
||||||
<nz-form-item class="setting-item">
|
<nz-form-item class="setting-item">
|
||||||
<nz-form-control class="setting-control checkbox">
|
<nz-form-control class="setting-control checkbox">
|
||||||
<label nz-checkbox formControlName="fileCompleted"
|
<label nz-checkbox formControlName="recordingStarted"
|
||||||
>录播文件完成</label
|
>录制开始</label
|
||||||
|
>
|
||||||
|
</nz-form-control>
|
||||||
|
</nz-form-item>
|
||||||
|
<nz-form-item class="setting-item">
|
||||||
|
<nz-form-control class="setting-control checkbox">
|
||||||
|
<label nz-checkbox formControlName="recordingFinished"
|
||||||
|
>录制完成</label
|
||||||
|
>
|
||||||
|
</nz-form-control>
|
||||||
|
</nz-form-item>
|
||||||
|
<nz-form-item class="setting-item">
|
||||||
|
<nz-form-control class="setting-control checkbox">
|
||||||
|
<label nz-checkbox formControlName="recordingCancelled"
|
||||||
|
>录制取消</label
|
||||||
|
>
|
||||||
|
</nz-form-control>
|
||||||
|
</nz-form-item>
|
||||||
|
<nz-form-item class="setting-item">
|
||||||
|
<nz-form-control class="setting-control checkbox">
|
||||||
|
<label nz-checkbox formControlName="videoFileCreated"
|
||||||
|
>视频文件创建</label
|
||||||
|
>
|
||||||
|
</nz-form-control>
|
||||||
|
</nz-form-item>
|
||||||
|
<nz-form-item class="setting-item">
|
||||||
|
<nz-form-control class="setting-control checkbox">
|
||||||
|
<label nz-checkbox formControlName="videoFileCompleted"
|
||||||
|
>视频文件完成</label
|
||||||
|
>
|
||||||
|
</nz-form-control>
|
||||||
|
</nz-form-item>
|
||||||
|
<nz-form-item class="setting-item">
|
||||||
|
<nz-form-control class="setting-control checkbox">
|
||||||
|
<label nz-checkbox formControlName="danmakuFileCreated"
|
||||||
|
>弹幕文件创建</label
|
||||||
|
>
|
||||||
|
</nz-form-control>
|
||||||
|
</nz-form-item>
|
||||||
|
<nz-form-item class="setting-item">
|
||||||
|
<nz-form-control class="setting-control checkbox">
|
||||||
|
<label nz-checkbox formControlName="danmakuFileCompleted"
|
||||||
|
>弹幕文件完成</label
|
||||||
|
>
|
||||||
|
</nz-form-control>
|
||||||
|
</nz-form-item>
|
||||||
|
<nz-form-item class="setting-item">
|
||||||
|
<nz-form-control class="setting-control checkbox">
|
||||||
|
<label nz-checkbox formControlName="rawDanmakuFileCreated"
|
||||||
|
>原始弹幕文件创建</label
|
||||||
|
>
|
||||||
|
</nz-form-control>
|
||||||
|
</nz-form-item>
|
||||||
|
<nz-form-item class="setting-item">
|
||||||
|
<nz-form-control class="setting-control checkbox">
|
||||||
|
<label nz-checkbox formControlName="rawDanmakuFileCompleted"
|
||||||
|
>原始弹幕文件完成</label
|
||||||
|
>
|
||||||
|
</nz-form-control>
|
||||||
|
</nz-form-item>
|
||||||
|
<nz-form-item class="setting-item">
|
||||||
|
<nz-form-control class="setting-control checkbox">
|
||||||
|
<label nz-checkbox formControlName="videoPostprocessingCompleted"
|
||||||
|
>视频后处理完成</label
|
||||||
>
|
>
|
||||||
</nz-form-control>
|
</nz-form-control>
|
||||||
</nz-form-item>
|
</nz-form-item>
|
||||||
|
@ -21,8 +21,17 @@ const DEFAULT_SETTINGS = {
|
|||||||
liveBegan: true,
|
liveBegan: true,
|
||||||
liveEnded: true,
|
liveEnded: true,
|
||||||
roomChange: true,
|
roomChange: true,
|
||||||
|
recordingStarted: true,
|
||||||
|
recordingFinished: true,
|
||||||
|
recordingCancelled: true,
|
||||||
|
videoFileCreated: true,
|
||||||
|
videoFileCompleted: true,
|
||||||
|
danmakuFileCreated: true,
|
||||||
|
danmakuFileCompleted: true,
|
||||||
|
rawDanmakuFileCreated: true,
|
||||||
|
rawDanmakuFileCompleted: true,
|
||||||
|
videoPostprocessingCompleted: true,
|
||||||
spaceNoEnough: true,
|
spaceNoEnough: true,
|
||||||
fileCompleted: true,
|
|
||||||
errorOccurred: true,
|
errorOccurred: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@ -57,7 +66,16 @@ export class WebhookEditDialogComponent implements OnChanges {
|
|||||||
liveBegan: [''],
|
liveBegan: [''],
|
||||||
liveEnded: [''],
|
liveEnded: [''],
|
||||||
roomChange: [''],
|
roomChange: [''],
|
||||||
fileCompleted: [''],
|
recordingStarted: [''],
|
||||||
|
recordingFinished: [''],
|
||||||
|
recordingCancelled: [''],
|
||||||
|
videoFileCreated: [''],
|
||||||
|
videoFileCompleted: [''],
|
||||||
|
danmakuFileCreated: [''],
|
||||||
|
danmakuFileCompleted: [''],
|
||||||
|
rawDanmakuFileCreated: [''],
|
||||||
|
rawDanmakuFileCompleted: [''],
|
||||||
|
videoPostprocessingCompleted: [''],
|
||||||
spaceNoEnough: [''],
|
spaceNoEnough: [''],
|
||||||
errorOccurred: [''],
|
errorOccurred: [''],
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
export const breakpoints = [
|
export const breakpoints = [
|
||||||
'(max-width: 534.98px)',
|
'(max-width: 534.98px)',
|
||||||
'(min-width: 535px) and (max-width: 1059.98px)',
|
'(min-width: 535px) and (max-width: 1199.98px)',
|
||||||
'(min-width: 1060px)',
|
'(min-width: 1200px)',
|
||||||
] as const;
|
] as const;
|
||||||
|
@ -259,8 +259,8 @@
|
|||||||
class="setting-label"
|
class="setting-label"
|
||||||
nzFor="recordGiftSend"
|
nzFor="recordGiftSend"
|
||||||
nzNoColon
|
nzNoColon
|
||||||
nzTooltipTitle="记录送礼信息到弹幕文件里"
|
nzTooltipTitle="记录礼物信息到弹幕文件里"
|
||||||
>记录送礼</nz-form-label
|
>记录礼物</nz-form-label
|
||||||
>
|
>
|
||||||
<nz-form-control class="setting-control switch">
|
<nz-form-control class="setting-control switch">
|
||||||
<nz-switch
|
<nz-switch
|
||||||
@ -281,6 +281,33 @@
|
|||||||
>覆盖全局设置</label
|
>覆盖全局设置</label
|
||||||
>
|
>
|
||||||
</nz-form-item>
|
</nz-form-item>
|
||||||
|
<nz-form-item class="setting-item">
|
||||||
|
<nz-form-label
|
||||||
|
class="setting-label"
|
||||||
|
nzFor="recordFreeGifts"
|
||||||
|
nzNoColon
|
||||||
|
nzTooltipTitle="记录免费礼物信息到弹幕文件里"
|
||||||
|
>记录免费礼物</nz-form-label
|
||||||
|
>
|
||||||
|
<nz-form-control class="setting-control switch">
|
||||||
|
<nz-switch
|
||||||
|
id="recordFreeGifts"
|
||||||
|
name="recordFreeGifts"
|
||||||
|
[(ngModel)]="model.danmaku.recordFreeGifts"
|
||||||
|
[disabled]="options.danmaku.recordFreeGifts === null"
|
||||||
|
></nz-switch>
|
||||||
|
</nz-form-control>
|
||||||
|
<label
|
||||||
|
nz-checkbox
|
||||||
|
[nzChecked]="options.danmaku.recordFreeGifts !== null"
|
||||||
|
(nzCheckedChange)="
|
||||||
|
options.danmaku.recordFreeGifts = $event
|
||||||
|
? globalSettings.danmaku.recordFreeGifts
|
||||||
|
: null
|
||||||
|
"
|
||||||
|
>覆盖全局设置</label
|
||||||
|
>
|
||||||
|
</nz-form-item>
|
||||||
<nz-form-item class="setting-item">
|
<nz-form-item class="setting-item">
|
||||||
<nz-form-label
|
<nz-form-label
|
||||||
class="setting-label"
|
class="setting-label"
|
||||||
|
@ -30,6 +30,10 @@
|
|||||||
flex: auto;
|
flex: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selector {
|
||||||
|
min-width: 6em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.reverse-button {
|
.reverse-button {
|
||||||
|
@ -53,8 +53,10 @@ export class ToolbarComponent implements OnInit, OnDestroy {
|
|||||||
readonly selections = [
|
readonly selections = [
|
||||||
{ label: '全部', value: DataSelection.ALL },
|
{ label: '全部', value: DataSelection.ALL },
|
||||||
{ label: '录制中', value: DataSelection.RECORDING },
|
{ label: '录制中', value: DataSelection.RECORDING },
|
||||||
|
{ label: '录制开', value: DataSelection.RECORDER_ENABLED },
|
||||||
{ label: '录制关', value: DataSelection.RECORDER_DISABLED },
|
{ label: '录制关', value: DataSelection.RECORDER_DISABLED },
|
||||||
{ label: '停止', value: DataSelection.STOPPED },
|
{ label: '运行', value: DataSelection.MONITOR_ENABLED },
|
||||||
|
{ label: '停止', value: DataSelection.MONITOR_DISABLED },
|
||||||
{ label: '直播', value: DataSelection.LIVING },
|
{ label: '直播', value: DataSelection.LIVING },
|
||||||
{ label: '轮播', value: DataSelection.ROUNDING },
|
{ label: '轮播', value: DataSelection.ROUNDING },
|
||||||
{ label: '闲置', value: DataSelection.PREPARING },
|
{ label: '闲置', value: DataSelection.PREPARING },
|
||||||
|
Loading…
Reference in New Issue
Block a user