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
|
||||
|
||||
- 修复录制错误: `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'
|
||||
__version__ = '1.3.2'
|
||||
__version__ = '1.4.0'
|
||||
__github__ = 'https://github.com/acgnhiki/blrec'
|
||||
|
@ -103,7 +103,7 @@ class Application:
|
||||
async def launch(self) -> None:
|
||||
self._setup()
|
||||
await self._task_manager.load_all_tasks()
|
||||
logger.info('Launched Application')
|
||||
logger.info(f'Launched Application v{__version__}')
|
||||
|
||||
async def exit(self) -> None:
|
||||
await self._exit()
|
||||
@ -136,50 +136,61 @@ class Application:
|
||||
|
||||
settings = await self._settings_manager.add_task_settings(room_id)
|
||||
await self._task_manager.add_task(settings)
|
||||
logger.info(f'Added task: {room_id}')
|
||||
|
||||
return room_id
|
||||
|
||||
async def remove_task(self, room_id: int) -> None:
|
||||
await self._task_manager.remove_task(room_id)
|
||||
await self._settings_manager.remove_task_settings(room_id)
|
||||
logger.info(f'Removed task: {room_id}')
|
||||
|
||||
async def remove_all_tasks(self) -> None:
|
||||
await self._task_manager.remove_all_tasks()
|
||||
await self._settings_manager.remove_all_task_settings()
|
||||
logger.info('Removed all tasks')
|
||||
|
||||
async def start_task(self, room_id: int) -> None:
|
||||
await self._task_manager.start_task(room_id)
|
||||
await self._settings_manager.mark_task_enabled(room_id)
|
||||
logger.info(f'Started task: {room_id}')
|
||||
|
||||
async def stop_task(self, room_id: int, force: bool = False) -> None:
|
||||
await self._task_manager.stop_task(room_id, force)
|
||||
await self._settings_manager.mark_task_disabled(room_id)
|
||||
logger.info(f'Stopped task: {room_id}')
|
||||
|
||||
async def start_all_tasks(self) -> None:
|
||||
await self._task_manager.start_all_tasks()
|
||||
await self._settings_manager.mark_all_tasks_enabled()
|
||||
logger.info('Started all tasks')
|
||||
|
||||
async def stop_all_tasks(self, force: bool = False) -> None:
|
||||
await self._task_manager.stop_all_tasks(force)
|
||||
await self._settings_manager.mark_all_tasks_disabled()
|
||||
logger.info('Stopped all tasks')
|
||||
|
||||
async def enable_task_recorder(self, room_id: int) -> None:
|
||||
await self._task_manager.enable_task_recorder(room_id)
|
||||
await self._settings_manager.mark_task_recorder_enabled(room_id)
|
||||
logger.info(f'Enabled task recorder: {room_id}')
|
||||
|
||||
async def disable_task_recorder(
|
||||
self, room_id: int, force: bool = False
|
||||
) -> None:
|
||||
await self._task_manager.disable_task_recorder(room_id, force)
|
||||
await self._settings_manager.mark_task_recorder_disabled(room_id)
|
||||
logger.info(f'Disabled task recorder: {room_id}')
|
||||
|
||||
async def enable_all_task_recorders(self) -> None:
|
||||
await self._task_manager.enable_all_task_recorders()
|
||||
await self._settings_manager.mark_all_task_recorders_enabled()
|
||||
logger.info('Enabled all task recorders')
|
||||
|
||||
async def disable_all_task_recorders(self, force: bool = False) -> None:
|
||||
await self._task_manager.disable_all_task_recorders(force)
|
||||
await self._settings_manager.mark_all_task_recorders_disabled()
|
||||
logger.info('Disabled all task recorders')
|
||||
|
||||
def get_task_data(self, room_id: int) -> TaskData:
|
||||
return self._task_manager.get_task_data(room_id)
|
||||
|
@ -68,11 +68,11 @@ class WebApi:
|
||||
params = {
|
||||
'room_id': room_id,
|
||||
'protocol': '0,1',
|
||||
'format': '0,2',
|
||||
'format': '0,1,2',
|
||||
'codec': '0,1',
|
||||
'qn': qn,
|
||||
'platform': 'web',
|
||||
'ptype': 16,
|
||||
'ptype': 8,
|
||||
}
|
||||
r = await self._get(self.GET_ROOM_PLAY_INFO_URL, params=params)
|
||||
return r['data']
|
||||
|
@ -2,7 +2,7 @@
|
||||
import asyncio
|
||||
import re
|
||||
import json
|
||||
from typing import Dict, List, Optional, cast
|
||||
from typing import Dict, List, cast
|
||||
|
||||
import aiohttp
|
||||
from tenacity import (
|
||||
@ -165,9 +165,11 @@ class Live:
|
||||
# the timestamp on the server at the moment in seconds
|
||||
return await self._api.get_timestamp()
|
||||
|
||||
async def get_live_stream_url(
|
||||
self, qn: QualityNumber = 10000, format: StreamFormat = 'flv'
|
||||
) -> Optional[str]:
|
||||
async def get_live_stream_urls(
|
||||
self,
|
||||
qn: QualityNumber = 10000,
|
||||
format: StreamFormat = 'flv',
|
||||
) -> List[str]:
|
||||
try:
|
||||
data = await self._api.get_room_play_info(self._room_id, qn)
|
||||
except Exception:
|
||||
@ -188,12 +190,13 @@ class Live:
|
||||
accept_qn = cast(List[QualityNumber], codec['accept_qn'])
|
||||
|
||||
if qn not in accept_qn:
|
||||
return None
|
||||
|
||||
return []
|
||||
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:
|
||||
if data['is_hidden']:
|
||||
|
@ -15,6 +15,7 @@ QualityNumber = Literal[
|
||||
|
||||
StreamFormat = Literal[
|
||||
'flv',
|
||||
'ts',
|
||||
'fmp4',
|
||||
]
|
||||
|
||||
|
@ -4,17 +4,21 @@ import logging
|
||||
from contextlib import suppress
|
||||
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 .danmaku_receiver import DanmakuReceiver, DanmuMsg
|
||||
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
||||
from .statistics import StatisticsCalculator
|
||||
from ..bili.live import Live
|
||||
from ..exception import exception_callback
|
||||
from ..exception import exception_callback, submit_exception
|
||||
from ..event.event_emitter import EventListener, EventEmitter
|
||||
from ..path import danmaku_path
|
||||
from ..core.models import GiftSendMsg, GuardBuyMsg, SuperChatMsg
|
||||
from ..danmaku.models import (
|
||||
Metadata, Danmu, GiftSendRecord, GuardBuyRecord, SuperChatRecord
|
||||
)
|
||||
@ -50,6 +54,7 @@ class DanmakuDumper(
|
||||
*,
|
||||
danmu_uname: bool = False,
|
||||
record_gift_send: bool = False,
|
||||
record_free_gifts: bool = False,
|
||||
record_guard_buy: bool = False,
|
||||
record_super_chat: bool = False,
|
||||
) -> None:
|
||||
@ -61,6 +66,7 @@ class DanmakuDumper(
|
||||
|
||||
self.danmu_uname = danmu_uname
|
||||
self.record_gift_send = record_gift_send
|
||||
self.record_free_gifts = record_free_gifts
|
||||
self.record_guard_buy = record_guard_buy
|
||||
self.record_super_chat = record_super_chat
|
||||
|
||||
@ -124,7 +130,7 @@ class DanmakuDumper(
|
||||
await self._cancel_dump_task()
|
||||
|
||||
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)
|
||||
|
||||
async def _cancel_dump_task(self) -> None:
|
||||
@ -133,7 +139,7 @@ class DanmakuDumper(
|
||||
await self._dump_task
|
||||
|
||||
@aio_task_with_room_id
|
||||
async def _dump(self) -> None:
|
||||
async def _do_dump(self) -> None:
|
||||
assert self._path is not None
|
||||
logger.debug('Started dumping danmaku')
|
||||
self._calculator.reset()
|
||||
@ -144,6 +150,25 @@ class DanmakuDumper(
|
||||
await self._emit('danmaku_file_created', self._path)
|
||||
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:
|
||||
msg = await self._receiver.get_message()
|
||||
if isinstance(msg, DanmuMsg):
|
||||
@ -152,9 +177,13 @@ class DanmakuDumper(
|
||||
elif isinstance(msg, GiftSendMsg):
|
||||
if not self.record_gift_send:
|
||||
continue
|
||||
await writer.write_gift_send_record(
|
||||
self._make_gift_send_record(msg)
|
||||
)
|
||||
record = 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):
|
||||
if not self.record_guard_buy:
|
||||
continue
|
||||
@ -169,11 +198,6 @@ class DanmakuDumper(
|
||||
)
|
||||
else:
|
||||
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:
|
||||
return Metadata(
|
||||
@ -235,7 +259,7 @@ class DanmakuDumper(
|
||||
ts=self._calc_stime(msg.timestamp * 1000),
|
||||
uid=msg.uid,
|
||||
user=msg.uname,
|
||||
price=msg.price,
|
||||
price=msg.price * msg.rate,
|
||||
time=msg.time,
|
||||
message=msg.message,
|
||||
)
|
||||
|
@ -92,6 +92,7 @@ class SuperChatMsg:
|
||||
gift_name: str
|
||||
count: int
|
||||
price: int
|
||||
rate: int
|
||||
time: int # duration in seconds
|
||||
message: str
|
||||
uid: int
|
||||
@ -105,6 +106,7 @@ class SuperChatMsg:
|
||||
gift_name=data['gift']['gift_name'],
|
||||
count=int(data['gift']['num']),
|
||||
price=int(data['price']),
|
||||
rate=int(data['rate']),
|
||||
time=int(data['time']),
|
||||
message=data['message'],
|
||||
uid=int(data['uid']),
|
||||
|
@ -4,22 +4,41 @@ import logging
|
||||
from contextlib import suppress
|
||||
|
||||
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 .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 ..utils.mixins import SwitchableMixin
|
||||
from ..logging.room_id import aio_task_with_room_id
|
||||
|
||||
|
||||
__all__ = 'RawDanmakuDumper',
|
||||
__all__ = 'RawDanmakuDumper', 'RawDanmakuDumperEventListener'
|
||||
|
||||
|
||||
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__(
|
||||
self,
|
||||
stream_recorder: StreamRecorder,
|
||||
@ -53,7 +72,7 @@ class RawDanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
|
||||
await self._cancel_dump_task()
|
||||
|
||||
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)
|
||||
|
||||
async def _cancel_dump_task(self) -> None:
|
||||
@ -62,16 +81,36 @@ class RawDanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
|
||||
await self._dump_task
|
||||
|
||||
@aio_task_with_room_id
|
||||
async def _dump(self) -> None:
|
||||
async def _do_dump(self) -> None:
|
||||
logger.debug('Started dumping raw danmaku')
|
||||
try:
|
||||
async with aiofiles.open(self._path, 'wt', encoding='utf8') as f:
|
||||
logger.info(f"Raw danmaku file created: '{self._path}'")
|
||||
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:
|
||||
danmu = await self._receiver.get_raw_danmaku()
|
||||
json_string = json.dumps(danmu, ensure_ascii=False)
|
||||
await f.write(json_string + '\n')
|
||||
finally:
|
||||
logger.info(f"Raw danmaku file completed: '{self._path}'")
|
||||
await self._emit('raw_danmaku_file_completed', self._path)
|
||||
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_dumper import DanmakuDumper, DanmakuDumperEventListener
|
||||
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 ..event.event_emitter import EventListener, EventEmitter
|
||||
from ..bili.live import Live
|
||||
@ -41,17 +41,33 @@ class RecorderEventListener(EventListener):
|
||||
...
|
||||
|
||||
async def on_video_file_created(
|
||||
self, path: str, record_start_time: int
|
||||
self, recorder: Recorder, path: str
|
||||
) -> 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,
|
||||
AsyncStoppableMixin,
|
||||
DanmakuDumperEventListener,
|
||||
RawDanmakuDumperEventListener,
|
||||
StreamRecorderEventListener,
|
||||
):
|
||||
def __init__(
|
||||
@ -75,6 +92,7 @@ class Recorder(
|
||||
disconnection_timeout: Optional[int] = None,
|
||||
danmu_uname: bool = False,
|
||||
record_gift_send: bool = False,
|
||||
record_free_gifts: bool = False,
|
||||
record_guard_buy: bool = False,
|
||||
record_super_chat: bool = False,
|
||||
save_cover: bool = False,
|
||||
@ -110,6 +128,7 @@ class Recorder(
|
||||
self._danmaku_receiver,
|
||||
danmu_uname=danmu_uname,
|
||||
record_gift_send=record_gift_send,
|
||||
record_free_gifts=record_free_gifts,
|
||||
record_guard_buy=record_guard_buy,
|
||||
record_super_chat=record_super_chat,
|
||||
)
|
||||
@ -119,6 +138,10 @@ class Recorder(
|
||||
self._raw_danmaku_receiver,
|
||||
)
|
||||
|
||||
@property
|
||||
def live(self) -> Live:
|
||||
return self._live
|
||||
|
||||
@property
|
||||
def recording(self) -> bool:
|
||||
return self._recording
|
||||
@ -175,6 +198,14 @@ class Recorder(
|
||||
def record_gift_send(self, value: bool) -> None:
|
||||
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
|
||||
def record_guard_buy(self) -> bool:
|
||||
return self._danmaku_dumper.record_guard_buy
|
||||
@ -246,6 +277,7 @@ class Recorder(
|
||||
async def _do_start(self) -> None:
|
||||
self._live_monitor.add_listener(self)
|
||||
self._danmaku_dumper.add_listener(self)
|
||||
self._raw_danmaku_dumper.add_listener(self)
|
||||
self._stream_recorder.add_listener(self)
|
||||
logger.debug('Started recorder')
|
||||
|
||||
@ -259,6 +291,7 @@ class Recorder(
|
||||
await self._stop_recording()
|
||||
self._live_monitor.remove_listener(self)
|
||||
self._danmaku_dumper.remove_listener(self)
|
||||
self._raw_danmaku_dumper.remove_listener(self)
|
||||
self._stream_recorder.remove_listener(self)
|
||||
logger.debug('Stopped recorder')
|
||||
|
||||
@ -306,18 +339,24 @@ class Recorder(
|
||||
async def on_video_file_created(
|
||||
self, path: str, record_start_time: int
|
||||
) -> 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:
|
||||
await self._emit('video_file_completed', path)
|
||||
await self._emit('video_file_completed', self, path)
|
||||
if self.save_cover:
|
||||
await self._save_cover_image(path)
|
||||
|
||||
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:
|
||||
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:
|
||||
logger.debug('Stream recording stopped')
|
||||
|
@ -1,5 +1,6 @@
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import errno
|
||||
import asyncio
|
||||
@ -40,7 +41,7 @@ from ..bili.typing import QualityNumber
|
||||
from ..flv.stream_processor import StreamProcessor, BaseOutputFileManager
|
||||
from ..utils.mixins import AsyncCooperationMix, AsyncStoppableMixin
|
||||
from ..path import escape_path
|
||||
from ..flv.exceptions import FlvStreamCorruptedError
|
||||
from ..flv.exceptions import FlvDataError, FlvStreamCorruptedError
|
||||
from ..bili.exceptions import (
|
||||
LiveRoomHidden, LiveRoomLocked, LiveRoomEncrypted, NoStreamUrlAvailable
|
||||
)
|
||||
@ -224,6 +225,7 @@ class StreamRecorder(
|
||||
|
||||
def _run(self) -> None:
|
||||
self._calculator.reset()
|
||||
self._use_candidate_stream: bool = False
|
||||
try:
|
||||
with tqdm(
|
||||
desc='Recording',
|
||||
@ -297,6 +299,7 @@ class StreamRecorder(
|
||||
self._stopped = True
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
self._handle_exception(e)
|
||||
raise
|
||||
|
||||
def _streaming_loop(self) -> None:
|
||||
@ -333,6 +336,10 @@ class StreamRecorder(
|
||||
self._stopped = True
|
||||
else:
|
||||
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:
|
||||
logger.warning(repr(e))
|
||||
url = self._get_live_stream_url()
|
||||
@ -368,10 +375,14 @@ class StreamRecorder(
|
||||
)
|
||||
def _get_live_stream_url(self) -> str:
|
||||
qn = self._real_quality_number or self.quality_number
|
||||
url = self._run_coroutine(self._live.get_live_stream_url(qn, 'flv'))
|
||||
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 url is None:
|
||||
if not urls:
|
||||
logger.info(
|
||||
f'The specified video quality ({qn}) is not available, '
|
||||
'using the original video quality (10000) instead.'
|
||||
@ -382,7 +393,17 @@ class StreamRecorder(
|
||||
logger.info(f'The specified video quality ({qn}) is available')
|
||||
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}'")
|
||||
|
||||
return url
|
||||
@ -545,9 +566,17 @@ class OutputFileManager(BaseOutputFileManager, AsyncCooperationMix):
|
||||
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.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),
|
||||
'user': dm.uname,
|
||||
}
|
||||
|
||||
try:
|
||||
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:
|
||||
elem.text = dm.text
|
||||
except ValueError:
|
||||
# ValueError: All strings must be XML compatible: Unicode or ASCII,
|
||||
# no NULL bytes or control characters
|
||||
elem.text = remove_control_characters(dm.text)
|
||||
|
||||
return ' ' + etree.tostring(elem, encoding='utf8').decode() + '\n'
|
||||
|
||||
def _serialize_gift_send_record(self, record: GiftSendRecord) -> str:
|
||||
|
@ -40,6 +40,9 @@ class GiftSendRecord:
|
||||
cointype: Literal['sliver', 'gold']
|
||||
price: int
|
||||
|
||||
def is_free_gift(self) -> bool:
|
||||
return self.cointype != 'gold'
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
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>
|
||||
<app-root></app-root>
|
||||
<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>
|
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,
|
||||
"timestamp": 1642737806856,
|
||||
"timestamp": 1644635235127,
|
||||
"index": "/index.html",
|
||||
"assetGroups": [
|
||||
{
|
||||
@ -11,17 +11,17 @@
|
||||
"ignoreVary": true
|
||||
},
|
||||
"urls": [
|
||||
"/103.9c8251484169c949.js",
|
||||
"/103.5b5d2a6e5a8a7479.js",
|
||||
"/146.92e3b29c4c754544.js",
|
||||
"/622.dd6c6ac77555edc7.js",
|
||||
"/622.03823b1714105423.js",
|
||||
"/66.97582e026891bf70.js",
|
||||
"/853.5697121b2e654d67.js",
|
||||
"/853.84ee7e1d7cff8913.js",
|
||||
"/common.858f777e9296e6f2.js",
|
||||
"/index.html",
|
||||
"/main.7a4da5a70e652c3f.js",
|
||||
"/main.5e1124f1a8c47971.js",
|
||||
"/manifest.webmanifest",
|
||||
"/polyfills.4b08448aee19bb22.js",
|
||||
"/runtime.c48b962c8225f379.js",
|
||||
"/runtime.0c70df55750c11ae.js",
|
||||
"/styles.1f581691b230dc4d.css"
|
||||
],
|
||||
"patterns": []
|
||||
@ -1633,11 +1633,11 @@
|
||||
],
|
||||
"dataGroups": [],
|
||||
"hashTable": {
|
||||
"/103.9c8251484169c949.js": "b111521f577092144d65506fa72c121543fd4446",
|
||||
"/103.5b5d2a6e5a8a7479.js": "cc0240f217015b6d4ddcc14f31fcc42e1c1c282a",
|
||||
"/146.92e3b29c4c754544.js": "3824de681dd1f982ea69a065cdf54d7a1e781f4d",
|
||||
"/622.dd6c6ac77555edc7.js": "5406594e418982532e138bf3c12ccbb1cfb09942",
|
||||
"/622.03823b1714105423.js": "86c61c37b53c951370ef2a16eb187cda666d7562",
|
||||
"/66.97582e026891bf70.js": "11cfd8acd3399fef42f0cf77d64aafc62c7e6994",
|
||||
"/853.5697121b2e654d67.js": "beb26b99743f6363c59c477b18be790b1e70d56e",
|
||||
"/853.84ee7e1d7cff8913.js": "6281853ef474fc543ac39fb47ec4a0a61ca875fa",
|
||||
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
|
||||
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
|
||||
"/assets/fill/.gitkeep": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
|
||||
@ -3232,11 +3232,11 @@
|
||||
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
|
||||
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
|
||||
"/common.858f777e9296e6f2.js": "b68ca68e1e214a2537d96935c23410126cc564dd",
|
||||
"/index.html": "0ed90c4296321165a7358a99ae67f911cc8b8a0f",
|
||||
"/main.7a4da5a70e652c3f.js": "6dd73cbf2d5aee6a29559f6357b1075aadd6fb99",
|
||||
"/manifest.webmanifest": "0c4534b4c868d756691b1b4372cecb2efce47c6d",
|
||||
"/index.html": "9e332051ad11197ce464047d2a13bc15016a3d70",
|
||||
"/main.5e1124f1a8c47971.js": "325ed4fbaa0160c18e06bb646431d1fa86f826f1",
|
||||
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
|
||||
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
|
||||
"/runtime.c48b962c8225f379.js": "ae674dc840fe5aa957d08253f56e21c4b772b93b",
|
||||
"/runtime.0c70df55750c11ae.js": "8e2f23cde1c56d8859adf4cd948753c1f8736d86",
|
||||
"/styles.1f581691b230dc4d.css": "6f5befbbad57c2b2e80aae855139744b8010d150"
|
||||
},
|
||||
"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):
|
||||
_SUFFIX_SET = frozenset(('.flv', '.mp4', '.xml', '.meta'))
|
||||
_SUFFIX_SET = frozenset(('.flv', '.mp4', '.xml', '.jsonl', '.jpg'))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
@ -9,10 +9,28 @@ from .models import (
|
||||
LiveEndedEventData,
|
||||
RoomChangeEvent,
|
||||
RoomChangeEventData,
|
||||
RecordingStartedEvent,
|
||||
RecordingStartedEventData,
|
||||
RecordingFinishedEvent,
|
||||
RecordingFinishedEventData,
|
||||
RecordingCancelledEvent,
|
||||
RecordingCancelledEventData,
|
||||
VideoFileCreatedEvent,
|
||||
VideoFileCreatedEventData,
|
||||
VideoFileCompletedEvent,
|
||||
VideoFileCompletedEventData,
|
||||
DanmakuFileCreatedEvent,
|
||||
DanmakuFileCreatedEventData,
|
||||
DanmakuFileCompletedEvent,
|
||||
DanmakuFileCompletedEventData,
|
||||
RawDanmakuFileCreatedEvent,
|
||||
RawDanmakuFileCreatedEventData,
|
||||
RawDanmakuFileCompletedEvent,
|
||||
RawDanmakuFileCompletedEventData,
|
||||
VideoPostprocessingCompletedEvent,
|
||||
VideoPostprocessingCompletedEventData,
|
||||
SpaceNoEnoughEvent,
|
||||
SpaceNoEnoughEventData,
|
||||
FileCompletedEvent,
|
||||
FileCompletedEventData,
|
||||
Error,
|
||||
ErrorData,
|
||||
)
|
||||
@ -31,10 +49,28 @@ __all__ = (
|
||||
'LiveEndedEventData',
|
||||
'RoomChangeEvent',
|
||||
'RoomChangeEventData',
|
||||
'RecordingStartedEvent',
|
||||
'RecordingStartedEventData',
|
||||
'RecordingFinishedEvent',
|
||||
'RecordingFinishedEventData',
|
||||
'RecordingCancelledEvent',
|
||||
'RecordingCancelledEventData',
|
||||
'VideoFileCreatedEvent',
|
||||
'VideoFileCreatedEventData',
|
||||
'VideoFileCompletedEvent',
|
||||
'VideoFileCompletedEventData',
|
||||
'DanmakuFileCreatedEvent',
|
||||
'DanmakuFileCreatedEventData',
|
||||
'DanmakuFileCompletedEvent',
|
||||
'DanmakuFileCompletedEventData',
|
||||
'RawDanmakuFileCreatedEvent',
|
||||
'RawDanmakuFileCreatedEventData',
|
||||
'RawDanmakuFileCompletedEvent',
|
||||
'RawDanmakuFileCompletedEventData',
|
||||
'VideoPostprocessingCompletedEvent',
|
||||
'VideoPostprocessingCompletedEventData',
|
||||
'SpaceNoEnoughEvent',
|
||||
'SpaceNoEnoughEventData',
|
||||
'FileCompletedEvent',
|
||||
'FileCompletedEventData',
|
||||
'Error',
|
||||
'ErrorData',
|
||||
)
|
||||
|
@ -9,18 +9,38 @@ from .models import (
|
||||
LiveEndedEventData,
|
||||
RoomChangeEvent,
|
||||
RoomChangeEventData,
|
||||
FileCompletedEvent,
|
||||
FileCompletedEventData,
|
||||
RecordingStartedEvent,
|
||||
RecordingStartedEventData,
|
||||
RecordingFinishedEvent,
|
||||
RecordingFinishedEventData,
|
||||
RecordingCancelledEvent,
|
||||
RecordingCancelledEventData,
|
||||
VideoFileCreatedEvent,
|
||||
VideoFileCreatedEventData,
|
||||
VideoFileCompletedEvent,
|
||||
VideoFileCompletedEventData,
|
||||
DanmakuFileCreatedEvent,
|
||||
DanmakuFileCreatedEventData,
|
||||
DanmakuFileCompletedEvent,
|
||||
DanmakuFileCompletedEventData,
|
||||
RawDanmakuFileCreatedEvent,
|
||||
RawDanmakuFileCreatedEventData,
|
||||
RawDanmakuFileCompletedEvent,
|
||||
RawDanmakuFileCompletedEventData,
|
||||
VideoPostprocessingCompletedEvent,
|
||||
VideoPostprocessingCompletedEventData,
|
||||
SpaceNoEnoughEvent,
|
||||
SpaceNoEnoughEventData,
|
||||
)
|
||||
from ..bili.live import Live
|
||||
from ..bili.models import RoomInfo
|
||||
from ..bili.live_monitor import LiveEventListener
|
||||
from ..core.recorder import RecorderEventListener
|
||||
from ..disk_space import SpaceEventListener
|
||||
from ..postprocess import PostprocessorEventListener
|
||||
if TYPE_CHECKING:
|
||||
from ..bili.live_monitor import LiveMonitor
|
||||
from ..core.recorder import Recorder
|
||||
from ..disk_space import SpaceMonitor, DiskUsage
|
||||
from ..postprocess import Postprocessor
|
||||
|
||||
@ -52,6 +72,74 @@ class LiveEventSubmitter(LiveEventListener):
|
||||
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):
|
||||
def __init__(self, space_monitor: SpaceMonitor) -> None:
|
||||
super().__init__()
|
||||
@ -62,13 +150,3 @@ class SpaceEventSubmitter(SpaceEventListener):
|
||||
) -> None:
|
||||
data = SpaceNoEnoughEventData(path, threshold, disk_usage)
|
||||
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:
|
||||
from ..bili.models import UserInfo, RoomInfo
|
||||
from ..disk_space.space_monitor import DiskUsage
|
||||
from ..exception import format_exception
|
||||
|
||||
|
||||
_D = TypeVar('_D', bound='BaseEventData')
|
||||
@ -80,6 +81,127 @@ class RoomChangeEvent(BaseEvent[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)
|
||||
class SpaceNoEnoughEventData(BaseEventData):
|
||||
path: str
|
||||
@ -93,23 +215,17 @@ class SpaceNoEnoughEvent(BaseEvent[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)
|
||||
class ErrorData(BaseEventData):
|
||||
name: 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)
|
||||
class Error(BaseEvent[ErrorData]):
|
||||
|
@ -8,10 +8,28 @@ from .models import (
|
||||
LiveEndedEventData,
|
||||
RoomChangeEvent,
|
||||
RoomChangeEventData,
|
||||
RecordingStartedEvent,
|
||||
RecordingStartedEventData,
|
||||
RecordingFinishedEvent,
|
||||
RecordingFinishedEventData,
|
||||
RecordingCancelledEvent,
|
||||
RecordingCancelledEventData,
|
||||
VideoFileCreatedEvent,
|
||||
VideoFileCreatedEventData,
|
||||
VideoFileCompletedEvent,
|
||||
VideoFileCompletedEventData,
|
||||
DanmakuFileCreatedEvent,
|
||||
DanmakuFileCreatedEventData,
|
||||
DanmakuFileCompletedEvent,
|
||||
DanmakuFileCompletedEventData,
|
||||
RawDanmakuFileCreatedEvent,
|
||||
RawDanmakuFileCreatedEventData,
|
||||
RawDanmakuFileCompletedEvent,
|
||||
RawDanmakuFileCompletedEventData,
|
||||
VideoPostprocessingCompletedEvent,
|
||||
VideoPostprocessingCompletedEventData,
|
||||
SpaceNoEnoughEvent,
|
||||
SpaceNoEnoughEventData,
|
||||
FileCompletedEvent,
|
||||
FileCompletedEventData,
|
||||
Error,
|
||||
ErrorData,
|
||||
)
|
||||
@ -21,8 +39,17 @@ Event = Union[
|
||||
LiveBeganEvent,
|
||||
LiveEndedEvent,
|
||||
RoomChangeEvent,
|
||||
RecordingStartedEvent,
|
||||
RecordingFinishedEvent,
|
||||
RecordingCancelledEvent,
|
||||
VideoFileCreatedEvent,
|
||||
VideoFileCompletedEvent,
|
||||
DanmakuFileCreatedEvent,
|
||||
DanmakuFileCompletedEvent,
|
||||
RawDanmakuFileCreatedEvent,
|
||||
RawDanmakuFileCompletedEvent,
|
||||
VideoPostprocessingCompletedEvent,
|
||||
SpaceNoEnoughEvent,
|
||||
FileCompletedEvent,
|
||||
Error,
|
||||
]
|
||||
|
||||
@ -30,7 +57,16 @@ EventData = Union[
|
||||
LiveBeganEventData,
|
||||
LiveEndedEventData,
|
||||
RoomChangeEventData,
|
||||
RecordingStartedEventData,
|
||||
RecordingFinishedEventData,
|
||||
RecordingCancelledEventData,
|
||||
VideoFileCreatedEventData,
|
||||
VideoFileCompletedEventData,
|
||||
DanmakuFileCreatedEventData,
|
||||
DanmakuFileCompletedEventData,
|
||||
RawDanmakuFileCreatedEventData,
|
||||
RawDanmakuFileCompletedEventData,
|
||||
VideoPostprocessingCompletedEventData,
|
||||
SpaceNoEnoughEventData,
|
||||
FileCompletedEventData,
|
||||
ErrorData,
|
||||
]
|
||||
|
@ -26,5 +26,4 @@ class ExceptionHandler(SwitchableMixin):
|
||||
|
||||
def _log_exception(self, exc: BaseException) -> None:
|
||||
exc_info = (type(exc), exc, exc.__traceback__)
|
||||
msg = 'Unhandled Exception'
|
||||
logger.critical(msg, exc_info=exc_info)
|
||||
logger.critical(type(exc).__name__, exc_info=exc_info)
|
||||
|
@ -3,6 +3,4 @@ import traceback
|
||||
|
||||
def format_exception(exc: BaseException) -> str:
|
||||
exc_info = (type(exc), exc, exc.__traceback__)
|
||||
errmsg = 'Unhandled exception in event loop\n'
|
||||
errmsg += ''.join(traceback.format_exception(*exc_info))
|
||||
return errmsg
|
||||
return ''.join(traceback.format_exception(*exc_info))
|
||||
|
@ -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
|
||||
|
||||
import attr
|
||||
|
||||
from .struct_io import StructReader, StructWriter
|
||||
from .io_protocols import RandomIO
|
||||
from .exceptions import InvalidFlvHeaderError, InvalidFlvTagError
|
||||
from .exceptions import FlvHeaderError, FlvDataError, FlvTagError
|
||||
from .models import (
|
||||
FlvTag,
|
||||
TagType,
|
||||
FlvHeader,
|
||||
FlvTagHeader,
|
||||
TAG_HEADER_SIZE,
|
||||
AUDIO_TAG_HEADER_SIZE,
|
||||
VIDEO_TAG_HEADER_SIZE,
|
||||
|
||||
AudioTag,
|
||||
AudioTagHeader,
|
||||
@ -40,7 +44,7 @@ class FlvParser:
|
||||
def parse_header(self) -> FlvHeader:
|
||||
signature = self._reader.read(3).decode()
|
||||
if signature != 'FLV':
|
||||
raise InvalidFlvHeaderError(signature)
|
||||
raise FlvHeaderError(signature)
|
||||
version = self._reader.read_ui8()
|
||||
type_flag = self._reader.read_ui8()
|
||||
data_offset = self._reader.read_ui32()
|
||||
@ -51,75 +55,98 @@ class FlvParser:
|
||||
|
||||
def parse_tag(self, *, no_body: bool = False) -> FlvTag:
|
||||
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
|
||||
arguments = dict(attr.asdict(tag_header), offset=offset)
|
||||
if tag_header.data_size <= 0:
|
||||
raise FlvTagError('No tag data', tag_header)
|
||||
|
||||
if tag_header.tag_type == TagType.AUDIO:
|
||||
audio_tag_header = self.parse_audio_tag_header()
|
||||
arguments.update(attr.asdict(audio_tag_header))
|
||||
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)
|
||||
|
||||
header_data = self._reader.read(AUDIO_TAG_HEADER_SIZE)
|
||||
body_size = tag_header.data_size - AUDIO_TAG_HEADER_SIZE
|
||||
if no_body:
|
||||
self._stream.seek(tag.tag_end_offset)
|
||||
self._stream.seek(body_size, SEEK_CUR)
|
||||
body = None
|
||||
else:
|
||||
body = self._reader.read(tag.body_size)
|
||||
tag = tag.evolve(body=body)
|
||||
body = self._reader.read(body_size)
|
||||
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) -> FlvTagHeader:
|
||||
flag = self._reader.read_ui8()
|
||||
def parse_flv_tag_header(self, data: bytes) -> FlvTagHeader:
|
||||
reader = StructReader(BytesIO(data))
|
||||
flag = reader.read_ui8()
|
||||
filtered = bool(flag & 0b0010_0000)
|
||||
if filtered:
|
||||
raise NotImplementedError('Unsupported Filtered FLV Tag')
|
||||
raise FlvDataError('Unsupported Filtered FLV Tag', data)
|
||||
tag_type = TagType(flag & 0b0001_1111)
|
||||
data_size = self._reader.read_ui24()
|
||||
timestamp = self._reader.read_ui24()
|
||||
timestamp_extended = self._reader.read_ui8()
|
||||
data_size = reader.read_ui24()
|
||||
timestamp = reader.read_ui24()
|
||||
timestamp_extended = reader.read_ui8()
|
||||
timestamp = timestamp_extended << 24 | timestamp
|
||||
stream_id = self._reader.read_ui24()
|
||||
tag_header = FlvTagHeader(
|
||||
stream_id = reader.read_ui24()
|
||||
return FlvTagHeader(
|
||||
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:
|
||||
flag = self._reader.read_ui8()
|
||||
def parse_audio_tag_header(self, data: bytes) -> AudioTagHeader:
|
||||
reader = StructReader(BytesIO(data))
|
||||
flag = reader.read_ui8()
|
||||
sound_format = SoundFormat(flag >> 4)
|
||||
if sound_format != SoundFormat.AAC:
|
||||
raise NotImplementedError(
|
||||
f'Unsupported sound format: {sound_format}'
|
||||
raise FlvDataError(
|
||||
f'Unsupported sound format: {sound_format}', data
|
||||
)
|
||||
sound_rate = SoundRate((flag >> 2) & 0b0000_0011)
|
||||
sound_size = SoundSize((flag >> 1) & 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(
|
||||
sound_format, sound_rate, sound_size, sound_type, aac_packet_type
|
||||
)
|
||||
|
||||
def parse_video_tag_header(self) -> VideoTagHeader:
|
||||
flag = self._reader.read_ui8()
|
||||
def parse_video_tag_header(self, data: bytes) -> VideoTagHeader:
|
||||
reader = StructReader(BytesIO(data))
|
||||
flag = reader.read_ui8()
|
||||
frame_type = FrameType(flag >> 4)
|
||||
codec_id = CodecID(flag & 0b0000_1111)
|
||||
if codec_id != CodecID.AVC:
|
||||
raise NotImplementedError(
|
||||
f'Unsupported video codec: {codec_id}'
|
||||
)
|
||||
avc_packet_type = AVCPacketType(self._reader.read_ui8())
|
||||
composition_time = self._reader.read_ui24()
|
||||
raise FlvDataError(f'Unsupported video codec: {codec_id}', data)
|
||||
avc_packet_type = AVCPacketType(reader.read_ui8())
|
||||
composition_time = reader.read_ui24()
|
||||
return VideoTagHeader(
|
||||
frame_type, codec_id, avc_packet_type, composition_time
|
||||
)
|
||||
@ -151,7 +178,7 @@ class FlvDumper:
|
||||
elif tag.is_script_tag():
|
||||
pass
|
||||
else:
|
||||
raise InvalidFlvTagError(tag.tag_type)
|
||||
raise FlvDataError(f'Unsupported tag type: {tag.tag_type}')
|
||||
|
||||
if tag.body is None:
|
||||
self._stream.seek(tag.tag_end_offset)
|
||||
@ -167,8 +194,8 @@ class FlvDumper:
|
||||
|
||||
def dump_audio_tag_header(self, tag: AudioTag) -> None:
|
||||
if tag.sound_format != SoundFormat.AAC:
|
||||
raise NotImplementedError(
|
||||
f'Unsupported sound format: {tag.sound_format}'
|
||||
raise FlvDataError(
|
||||
f'Unsupported sound format: {tag.sound_format}', tag
|
||||
)
|
||||
self._writer.write_ui8(
|
||||
(tag.sound_format.value << 4) |
|
||||
@ -180,9 +207,7 @@ class FlvDumper:
|
||||
|
||||
def dump_video_tag_header(self, tag: VideoTag) -> None:
|
||||
if tag.codec_id != CodecID.AVC:
|
||||
raise NotImplementedError(
|
||||
f'Unsupported video codec: {tag.codec_id}'
|
||||
)
|
||||
raise FlvDataError(f'Unsupported video codec: {tag.codec_id}', tag)
|
||||
self._writer.write_ui8(
|
||||
(tag.frame_type.value << 4) | tag.codec_id.value
|
||||
)
|
||||
|
@ -137,8 +137,10 @@ class VideoTagHeader:
|
||||
composition_time: Optional[int]
|
||||
|
||||
|
||||
TAG_HEADER_SIZE: Final[int] = 11
|
||||
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')
|
||||
|
@ -22,7 +22,7 @@ from .io import FlvReader, FlvWriter
|
||||
from .io_protocols import RandomIO
|
||||
from .utils import format_offest, format_timestamp
|
||||
from .exceptions import (
|
||||
FlvDataError,
|
||||
FlvTagError,
|
||||
FlvStreamCorruptedError,
|
||||
AudioParametersChanged,
|
||||
VideoParametersChanged,
|
||||
@ -32,8 +32,8 @@ from .exceptions import (
|
||||
)
|
||||
from .common import (
|
||||
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,
|
||||
is_sequence_header
|
||||
)
|
||||
from ..path import extra_metadata_path
|
||||
|
||||
@ -71,6 +71,7 @@ class StreamProcessor:
|
||||
self._data_analyser = DataAnalyser()
|
||||
|
||||
self._metadata = metadata.copy() if metadata else {}
|
||||
self._metadata_tag: ScriptTag
|
||||
|
||||
self._disable_limit = disable_limit
|
||||
self._analyse_data = analyse_data
|
||||
@ -85,9 +86,9 @@ class StreamProcessor:
|
||||
|
||||
self._delta: int = 0
|
||||
self._has_audio: bool = False
|
||||
self._metadata_tag: ScriptTag
|
||||
self._last_tags: List[FlvTag] = []
|
||||
self._join_points: List[JoinPoint] = []
|
||||
self._resetting_file: bool = False
|
||||
|
||||
@property
|
||||
def filesize_limit(self) -> int:
|
||||
@ -162,16 +163,29 @@ class StreamProcessor:
|
||||
def _need_to_finalize(self) -> bool:
|
||||
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()
|
||||
if not self._disable_limit:
|
||||
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_reader = FlvReader(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:
|
||||
curr_path = self._file_manager.curr_path
|
||||
@ -182,26 +196,7 @@ class StreamProcessor:
|
||||
self._update_metadata_tag()
|
||||
self._file_manager.close_file()
|
||||
|
||||
if self._analyse_data:
|
||||
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')
|
||||
logger.debug(f'Complete file: {curr_path}')
|
||||
|
||||
def _process_stream(self, stream: RandomIO) -> None:
|
||||
logger.debug(f'Processing the {self._stream_count}th stream...')
|
||||
@ -227,6 +222,9 @@ class StreamProcessor:
|
||||
def _process_initial_stream(
|
||||
self, flv_header: FlvHeader, first_data_tag: FlvTag
|
||||
) -> None:
|
||||
if self._resetting_file:
|
||||
self._reset_file()
|
||||
else:
|
||||
self._new_file()
|
||||
|
||||
try:
|
||||
@ -234,7 +232,8 @@ class StreamProcessor:
|
||||
self._transfer_meta_tags()
|
||||
self._transfer_first_data_tag(first_data_tag)
|
||||
except Exception:
|
||||
self._reset()
|
||||
self._last_tags = []
|
||||
self._resetting_file = True
|
||||
raise
|
||||
else:
|
||||
del flv_header, first_data_tag
|
||||
@ -376,8 +375,6 @@ class StreamProcessor:
|
||||
except EOFError:
|
||||
logger.debug('The input stream exhausted')
|
||||
break
|
||||
except FlvDataError as e:
|
||||
raise FlvStreamCorruptedError(repr(e))
|
||||
except Exception as e:
|
||||
logger.debug(f'Failed to read data, due to: {repr(e)}')
|
||||
raise
|
||||
@ -493,6 +490,8 @@ class StreamProcessor:
|
||||
return header
|
||||
|
||||
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:
|
||||
self._delta = -tag.timestamp
|
||||
logger.warning('Incorrect timestamp: {}, new delta: {}'.format(
|
||||
@ -500,9 +499,9 @@ class StreamProcessor:
|
||||
))
|
||||
|
||||
def _correct_ts(self, tag: FlvTag, delta: int) -> FlvTag:
|
||||
if delta == 0:
|
||||
if delta == 0 and tag.timestamp >= 0:
|
||||
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:
|
||||
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:
|
||||
super().__init__(stream)
|
||||
self._last_tag: Optional[FlvTag] = None
|
||||
|
@ -1,3 +1,4 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import PurePath
|
||||
@ -35,7 +36,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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._completed_files: List[str] = []
|
||||
|
||||
@property
|
||||
def recorder(self) -> Recorder:
|
||||
return self._recorder
|
||||
|
||||
@property
|
||||
def status(self) -> PostprocessorStatus:
|
||||
return self._status
|
||||
@ -87,10 +94,14 @@ class Postprocessor(
|
||||
# clear completed files of previous recording
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
async def _do_start(self) -> None:
|
||||
@ -143,7 +154,7 @@ class Postprocessor(
|
||||
|
||||
self._completed_files.append(result_path)
|
||||
await self._emit(
|
||||
'file_completed', self._live.room_id, result_path
|
||||
'video_postprocessing_completed', self, result_path,
|
||||
)
|
||||
except Exception as exc:
|
||||
submit_exception(exc)
|
||||
|
@ -18,13 +18,10 @@ from pydantic import BaseModel as PydanticBaseModel
|
||||
from pydantic import Field, BaseSettings, validator, PrivateAttr, DirectoryPath
|
||||
from pydantic.networks import HttpUrl, EmailStr
|
||||
|
||||
import typer
|
||||
|
||||
from ..bili.typing import QualityNumber
|
||||
from ..postprocess import DeleteStrategy
|
||||
from ..logging.typing import LOG_LEVEL
|
||||
from ..utils.string import camel_case
|
||||
from ..path.helpers import file_exists, create_file
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -67,22 +64,8 @@ __all__ = (
|
||||
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):
|
||||
settings_file: Annotated[
|
||||
str, Field(env='config', default_factory=settings_file_factory)
|
||||
]
|
||||
settings_file: Annotated[str, Field(env='config')] = DEFAULT_SETTINGS_PATH
|
||||
out_dir: Optional[str] = None
|
||||
api_key: Annotated[
|
||||
Optional[str],
|
||||
@ -98,7 +81,6 @@ _V = TypeVar('_V')
|
||||
|
||||
class BaseModel(PydanticBaseModel):
|
||||
class Config:
|
||||
extra = 'forbid'
|
||||
validate_assignment = True
|
||||
anystr_strip_whitespace = True
|
||||
allow_population_by_field_name = True
|
||||
@ -133,6 +115,7 @@ class HeaderSettings(HeaderOptions):
|
||||
class DanmakuOptions(BaseModel):
|
||||
danmu_uname: Optional[bool]
|
||||
record_gift_send: Optional[bool]
|
||||
record_free_gifts: Optional[bool]
|
||||
record_guard_buy: Optional[bool]
|
||||
record_super_chat: Optional[bool]
|
||||
save_raw_danmaku: Optional[bool]
|
||||
@ -141,6 +124,7 @@ class DanmakuOptions(BaseModel):
|
||||
class DanmakuSettings(DanmakuOptions):
|
||||
danmu_uname: bool = False
|
||||
record_gift_send: bool = True
|
||||
record_free_gifts: bool = True
|
||||
record_guard_buy: bool = True
|
||||
record_super_chat: bool = True
|
||||
save_raw_danmaku: bool = False
|
||||
@ -302,13 +286,13 @@ class SpaceSettings(BaseModel):
|
||||
|
||||
@validator('check_interval')
|
||||
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)
|
||||
return value
|
||||
|
||||
@validator('space_threshold')
|
||||
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)
|
||||
return value
|
||||
|
||||
@ -375,8 +359,17 @@ class WebHookEventSettings(BaseModel):
|
||||
live_began: bool = True
|
||||
live_ended: 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
|
||||
file_completed: bool = True
|
||||
error_occurred: bool = True
|
||||
|
||||
|
||||
|
@ -47,6 +47,7 @@ class TaskParam:
|
||||
# DanmakuSettings
|
||||
danmu_uname: bool
|
||||
record_gift_send: bool
|
||||
record_free_gifts: bool
|
||||
record_guard_buy: bool
|
||||
record_super_chat: bool
|
||||
save_raw_danmaku: bool
|
||||
|
@ -22,7 +22,7 @@ from ..postprocess import Postprocessor, PostprocessorStatus, DeleteStrategy
|
||||
from ..postprocess.remuxer import RemuxProgress
|
||||
from ..flv.metadata_injector import InjectProgress
|
||||
from ..event.event_submitters import (
|
||||
LiveEventSubmitter, PostprocessorEventSubmitter
|
||||
LiveEventSubmitter, RecorderEventSubmitter, PostprocessorEventSubmitter
|
||||
)
|
||||
from ..logging.room_id import aio_task_with_room_id
|
||||
|
||||
@ -44,6 +44,7 @@ class RecordTask:
|
||||
user_agent: str = '',
|
||||
danmu_uname: bool = False,
|
||||
record_gift_send: bool = False,
|
||||
record_free_gifts: bool = False,
|
||||
record_guard_buy: bool = False,
|
||||
record_super_chat: bool = False,
|
||||
save_cover: bool = False,
|
||||
@ -68,6 +69,7 @@ class RecordTask:
|
||||
self._user_agent = user_agent
|
||||
self._danmu_uname = danmu_uname
|
||||
self._record_gift_send = record_gift_send
|
||||
self._record_free_gifts = record_free_gifts
|
||||
self._record_guard_buy = record_guard_buy
|
||||
self._record_super_chat = record_super_chat
|
||||
self._save_cover = save_cover
|
||||
@ -239,6 +241,14 @@ class RecordTask:
|
||||
def record_gift_send(self, value: bool) -> None:
|
||||
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
|
||||
def record_guard_buy(self) -> bool:
|
||||
return self._recorder.record_guard_buy
|
||||
@ -442,6 +452,7 @@ class RecordTask:
|
||||
self._setup_live_monitor()
|
||||
self._setup_live_event_submitter()
|
||||
self._setup_recorder()
|
||||
self._setup_recorder_event_submitter()
|
||||
self._setup_postprocessor()
|
||||
self._setup_postprocessor_event_submitter()
|
||||
|
||||
@ -468,6 +479,7 @@ class RecordTask:
|
||||
disconnection_timeout=self._disconnection_timeout,
|
||||
danmu_uname=self._danmu_uname,
|
||||
record_gift_send=self._record_gift_send,
|
||||
record_free_gifts=self._record_free_gifts,
|
||||
record_guard_buy=self._record_guard_buy,
|
||||
record_super_chat=self._record_super_chat,
|
||||
save_cover=self._save_cover,
|
||||
@ -476,9 +488,8 @@ class RecordTask:
|
||||
duration_limit=self._duration_limit,
|
||||
)
|
||||
|
||||
def _setup_postprocessor_event_submitter(self) -> None:
|
||||
self._postprocessor_event_submitter = \
|
||||
PostprocessorEventSubmitter(self._postprocessor)
|
||||
def _setup_recorder_event_submitter(self) -> None:
|
||||
self._recorder_event_submitter = RecorderEventSubmitter(self._recorder)
|
||||
|
||||
def _setup_postprocessor(self) -> None:
|
||||
self._postprocessor = Postprocessor(
|
||||
@ -489,8 +500,14 @@ class RecordTask:
|
||||
delete_source=self._delete_source,
|
||||
)
|
||||
|
||||
def _setup_postprocessor_event_submitter(self) -> None:
|
||||
self._postprocessor_event_submitter = \
|
||||
PostprocessorEventSubmitter(self._postprocessor)
|
||||
|
||||
async def _destroy(self) -> None:
|
||||
self._destroy_postprocessor_event_submitter()
|
||||
self._destroy_postprocessor()
|
||||
self._destroy_recorder_event_submitter()
|
||||
self._destroy_recorder()
|
||||
self._destroy_live_event_submitter()
|
||||
self._destroy_live_monitor()
|
||||
@ -508,8 +525,11 @@ class RecordTask:
|
||||
def _destroy_recorder(self) -> None:
|
||||
del self._recorder
|
||||
|
||||
def _destroy_postprocessor_event_submitter(self) -> None:
|
||||
del self._postprocessor_event_submitter
|
||||
def _destroy_recorder_event_submitter(self) -> None:
|
||||
del self._recorder_event_submitter
|
||||
|
||||
def _destroy_postprocessor(self) -> None:
|
||||
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.danmu_uname = settings.danmu_uname
|
||||
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_super_chat = settings.record_super_chat
|
||||
task.save_raw_danmaku = settings.save_raw_danmaku
|
||||
@ -259,6 +260,7 @@ class RecordTaskManager:
|
||||
cookie=task.cookie,
|
||||
danmu_uname=task.danmu_uname,
|
||||
record_gift_send=task.record_gift_send,
|
||||
record_free_gifts=task.record_free_gifts,
|
||||
record_guard_buy=task.record_guard_buy,
|
||||
record_super_chat=task.record_super_chat,
|
||||
save_cover=task.save_cover,
|
||||
|
@ -1,16 +1,15 @@
|
||||
import os
|
||||
import logging
|
||||
import secrets
|
||||
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.exceptions import HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import ValidationError
|
||||
from pkg_resources import resource_filename
|
||||
|
||||
from . import security
|
||||
from .routers import (
|
||||
tasks, settings, application, validation, websockets, update
|
||||
)
|
||||
@ -18,33 +17,28 @@ from .schemas import ResponseMessage
|
||||
from ..setting import EnvSettings, Settings
|
||||
from ..application import Application
|
||||
from ..exception import NotFoundError, ExistsError, ForbiddenError
|
||||
from ..path.helpers import file_exists, create_file
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_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.update_from_env_settings(_env_settings)
|
||||
|
||||
app = Application(_settings)
|
||||
|
||||
if _env_settings.api_key is None:
|
||||
_dependencies = None
|
||||
else:
|
||||
async def validate_api_key(
|
||||
x_api_key: Optional[str] = Header(None)
|
||||
) -> 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)]
|
||||
security.api_key = _env_settings.api_key
|
||||
_dependencies = [Depends(security.authenticate)]
|
||||
|
||||
api = FastAPI(
|
||||
title='Bilibili live streaming recorder web API',
|
||||
|
@ -1,12 +1,13 @@
|
||||
import logging
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from websockets import ConnectionClosed # type: ignore
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
from ...event import EventCenter
|
||||
from ...event.typing import Event
|
||||
@ -33,7 +34,8 @@ async def receive_events(websocket: WebSocket) -> None:
|
||||
|
||||
async def send_event(event: Event) -> None:
|
||||
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:
|
||||
logger.debug(f'Events websocket closed: {repr(e)}')
|
||||
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,
|
||||
LiveEndedEvent,
|
||||
RoomChangeEvent,
|
||||
RecordingStartedEvent,
|
||||
RecordingFinishedEvent,
|
||||
RecordingCancelledEvent,
|
||||
VideoFileCreatedEvent,
|
||||
VideoFileCompletedEvent,
|
||||
DanmakuFileCreatedEvent,
|
||||
DanmakuFileCompletedEvent,
|
||||
RawDanmakuFileCreatedEvent,
|
||||
RawDanmakuFileCompletedEvent,
|
||||
SpaceNoEnoughEvent,
|
||||
FileCompletedEvent,
|
||||
VideoPostprocessingCompletedEvent,
|
||||
)
|
||||
from ..event.typing import Event
|
||||
|
||||
@ -37,8 +46,26 @@ class WebHook:
|
||||
types.add(LiveEndedEvent)
|
||||
if settings.room_change:
|
||||
types.add(RoomChangeEvent)
|
||||
if settings.file_completed:
|
||||
types.add(FileCompletedEvent)
|
||||
if settings.recording_started:
|
||||
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:
|
||||
types.add(SpaceNoEnoughEvent)
|
||||
|
||||
|
@ -11,7 +11,7 @@ from tenacity import (
|
||||
|
||||
from .models import WebHook
|
||||
from ..utils.mixins import SwitchableMixin
|
||||
from ..exception import ExceptionCenter, format_exception
|
||||
from ..exception import ExceptionCenter
|
||||
from ..event import EventCenter, Error, ErrorData
|
||||
from ..event.typing import Event
|
||||
from .. import __prog__, __version__
|
||||
@ -57,11 +57,7 @@ class WebHookEmitter(SwitchableMixin):
|
||||
self._send_request(url, event.asdict())
|
||||
|
||||
def _send_exception(self, url: str, exc: BaseException) -> None:
|
||||
data = ErrorData(
|
||||
name=type(exc).__name__,
|
||||
detail=format_exception(exc),
|
||||
)
|
||||
payload = Error.from_data(data).asdict()
|
||||
payload = Error.from_data(ErrorData.from_exc(exc)).asdict()
|
||||
self._send_request(url, payload)
|
||||
|
||||
def _send_request(self, url: str, payload: Dict[str, Any]) -> None:
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
} from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { catchError, retry } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class AuthInterceptor implements HttpInterceptor {
|
||||
@ -26,11 +26,15 @@ export class AuthInterceptor implements HttpInterceptor {
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
if (error.status === 401) {
|
||||
// Unauthorized
|
||||
if (this.auth.hasApiKey()) {
|
||||
this.auth.removeApiKey();
|
||||
}
|
||||
const apiKey = window.prompt('API Key:') ?? '';
|
||||
this.auth.setApiKey(apiKey);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
}),
|
||||
retry(3)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -4,8 +4,17 @@ export type Event =
|
||||
| LiveBeganEvent
|
||||
| LiveEndedEvent
|
||||
| RoomChangeEvent
|
||||
| RecordingStartedEvent
|
||||
| RecordingFinishedEvent
|
||||
| RecordingCancelledEvent
|
||||
| VideoFileCreatedEvent
|
||||
| VideoFileCompletedEvent
|
||||
| DanmakuFileCreatedEvent
|
||||
| DanmakuFileCompletedEvent
|
||||
| RawDanmakuFileCreatedEvent
|
||||
| RawDanmakuFileCompletedEvent
|
||||
| SpaceNoEnoughEvent
|
||||
| FilesAvailableEvent;
|
||||
| VideoPostprocessingCompletedEvent;
|
||||
|
||||
export interface LiveBeganEvent {
|
||||
readonly type: 'LiveBeganEvent';
|
||||
@ -29,6 +38,75 @@ export interface RoomChangeEvent {
|
||||
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 {
|
||||
readonly type: 'SpaceNoEnoughEvent';
|
||||
@ -39,13 +117,6 @@ export interface SpaceNoEnoughEvent {
|
||||
};
|
||||
}
|
||||
|
||||
export interface FilesAvailableEvent {
|
||||
readonly type: 'FilesAvailableEvent';
|
||||
readonly data: {
|
||||
files: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DiskUsage {
|
||||
total: number;
|
||||
used: number;
|
||||
|
@ -9,6 +9,10 @@ const API_KEY_STORAGE_KEY = 'app-api-key';
|
||||
export class AuthService {
|
||||
constructor(private storage: StorageService) {}
|
||||
|
||||
hasApiKey(): boolean {
|
||||
return this.storage.hasData(API_KEY_STORAGE_KEY);
|
||||
}
|
||||
|
||||
getApiKey(): string {
|
||||
return this.storage.getData(API_KEY_STORAGE_KEY) ?? '';
|
||||
}
|
||||
@ -16,4 +20,8 @@ export class AuthService {
|
||||
setApiKey(value: string): void {
|
||||
this.storage.setData(API_KEY_STORAGE_KEY, value);
|
||||
}
|
||||
|
||||
removeApiKey() {
|
||||
this.storage.removeData(API_KEY_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
@ -3,8 +3,8 @@
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
nzTooltipTitle="记录送礼信息到弹幕文件里"
|
||||
>记录送礼</nz-form-label
|
||||
nzTooltipTitle="记录礼物信息到弹幕文件里"
|
||||
>记录礼物</nz-form-label
|
||||
>
|
||||
<nz-form-control
|
||||
class="setting-control switch"
|
||||
@ -16,6 +16,23 @@
|
||||
<nz-switch formControlName="recordGiftSend"></nz-switch>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item" appSwitchActionable>
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
nzTooltipTitle="记录免费礼物信息到弹幕文件里"
|
||||
>记录免费礼物</nz-form-label
|
||||
>
|
||||
<nz-form-control
|
||||
class="setting-control switch"
|
||||
[nzWarningTip]="syncFailedWarningTip"
|
||||
[nzValidateStatus]="
|
||||
syncStatus.recordFreeGifts ? recordFreeGiftsControl : 'warning'
|
||||
"
|
||||
>
|
||||
<nz-switch formControlName="recordFreeGifts"></nz-switch>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item" appSwitchActionable>
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
|
@ -40,6 +40,7 @@ export class DanmakuSettingsComponent implements OnInit, OnChanges {
|
||||
this.settingsForm = formBuilder.group({
|
||||
danmuUname: [''],
|
||||
recordGiftSend: [''],
|
||||
recordFreeGifts: [''],
|
||||
recordGuardBuy: [''],
|
||||
recordSuperChat: [''],
|
||||
saveRawDanmaku: [''],
|
||||
@ -54,6 +55,10 @@ export class DanmakuSettingsComponent implements OnInit, OnChanges {
|
||||
return this.settingsForm.get('recordGiftSend') as FormControl;
|
||||
}
|
||||
|
||||
get recordFreeGiftsControl() {
|
||||
return this.settingsForm.get('recordFreeGifts') as FormControl;
|
||||
}
|
||||
|
||||
get recordGuardBuyControl() {
|
||||
return this.settingsForm.get('recordGuardBuy') as FormControl;
|
||||
}
|
||||
|
@ -33,6 +33,8 @@ export class DiskSpaceSettingsComponent implements OnInit, OnChanges {
|
||||
readonly syncFailedWarningTip = SYNC_FAILED_WARNING_TIP;
|
||||
|
||||
readonly intervalOptions = [
|
||||
{ label: '10 秒', value: 10 },
|
||||
{ label: '30 秒', value: 30 },
|
||||
{ label: '1 分钟', value: 60 },
|
||||
{ label: '3 分钟', value: 180 },
|
||||
{ label: '5 分钟', value: 300 },
|
||||
@ -44,6 +46,7 @@ export class DiskSpaceSettingsComponent implements OnInit, OnChanges {
|
||||
{ label: '3 GB', value: 1024 ** 3 * 3 },
|
||||
{ label: '5 GB', value: 1024 ** 3 * 5 },
|
||||
{ label: '10 GB', value: 1024 ** 3 * 10 },
|
||||
{ label: '20 GB', value: 1024 ** 3 * 20 },
|
||||
];
|
||||
|
||||
constructor(
|
||||
|
@ -10,6 +10,7 @@ export type HeaderOptions = Nullable<HeaderSettings>;
|
||||
export interface DanmakuSettings {
|
||||
danmuUname: boolean;
|
||||
recordGiftSend: boolean;
|
||||
recordFreeGifts: boolean;
|
||||
recordGuardBuy: boolean;
|
||||
recordSuperChat: boolean;
|
||||
saveRawDanmaku: boolean;
|
||||
@ -164,8 +165,17 @@ export interface WebhookEventSettings {
|
||||
liveBegan: boolean;
|
||||
liveEnded: 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;
|
||||
fileCompleted: boolean;
|
||||
errorOccurred: boolean;
|
||||
}
|
||||
|
||||
|
@ -62,8 +62,71 @@
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-control class="setting-control checkbox">
|
||||
<label nz-checkbox formControlName="fileCompleted"
|
||||
>录播文件完成</label
|
||||
<label nz-checkbox formControlName="recordingStarted"
|
||||
>录制开始</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-item>
|
||||
|
@ -21,8 +21,17 @@ const DEFAULT_SETTINGS = {
|
||||
liveBegan: true,
|
||||
liveEnded: 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,
|
||||
fileCompleted: true,
|
||||
errorOccurred: true,
|
||||
} as const;
|
||||
|
||||
@ -57,7 +66,16 @@ export class WebhookEditDialogComponent implements OnChanges {
|
||||
liveBegan: [''],
|
||||
liveEnded: [''],
|
||||
roomChange: [''],
|
||||
fileCompleted: [''],
|
||||
recordingStarted: [''],
|
||||
recordingFinished: [''],
|
||||
recordingCancelled: [''],
|
||||
videoFileCreated: [''],
|
||||
videoFileCompleted: [''],
|
||||
danmakuFileCreated: [''],
|
||||
danmakuFileCompleted: [''],
|
||||
rawDanmakuFileCreated: [''],
|
||||
rawDanmakuFileCompleted: [''],
|
||||
videoPostprocessingCompleted: [''],
|
||||
spaceNoEnough: [''],
|
||||
errorOccurred: [''],
|
||||
});
|
||||
|
@ -1,5 +1,5 @@
|
||||
export const breakpoints = [
|
||||
'(max-width: 534.98px)',
|
||||
'(min-width: 535px) and (max-width: 1059.98px)',
|
||||
'(min-width: 1060px)',
|
||||
'(min-width: 535px) and (max-width: 1199.98px)',
|
||||
'(min-width: 1200px)',
|
||||
] as const;
|
||||
|
@ -259,8 +259,8 @@
|
||||
class="setting-label"
|
||||
nzFor="recordGiftSend"
|
||||
nzNoColon
|
||||
nzTooltipTitle="记录送礼信息到弹幕文件里"
|
||||
>记录送礼</nz-form-label
|
||||
nzTooltipTitle="记录礼物信息到弹幕文件里"
|
||||
>记录礼物</nz-form-label
|
||||
>
|
||||
<nz-form-control class="setting-control switch">
|
||||
<nz-switch
|
||||
@ -281,6 +281,33 @@
|
||||
>覆盖全局设置</label
|
||||
>
|
||||
</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-label
|
||||
class="setting-label"
|
||||
|
@ -30,6 +30,10 @@
|
||||
flex: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.selector {
|
||||
min-width: 6em;
|
||||
}
|
||||
}
|
||||
|
||||
.reverse-button {
|
||||
|
@ -53,8 +53,10 @@ export class ToolbarComponent implements OnInit, OnDestroy {
|
||||
readonly selections = [
|
||||
{ label: '全部', value: DataSelection.ALL },
|
||||
{ label: '录制中', value: DataSelection.RECORDING },
|
||||
{ label: '录制开', value: DataSelection.RECORDER_ENABLED },
|
||||
{ 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.ROUNDING },
|
||||
{ label: '闲置', value: DataSelection.PREPARING },
|
||||
|
Loading…
Reference in New Issue
Block a user