release: 1.4.0

close #15
fix #16
close #18
close #19
fix #20
This commit is contained in:
acgnhik 2022-02-12 11:29:55 +08:00
parent 451e744ceb
commit 34b10a8a2d
60 changed files with 1188 additions and 292 deletions

2
.dockerignore Normal file
View File

@ -0,0 +1,2 @@
*/__pycache__*
*/.mypy_cache*

View File

@ -1,5 +1,17 @@
# 更新日志 # 更新日志
## 1.4.0
- 适应数据有问题的流服务器 gotcha08 (issue #13)
- 支持 Docker (issue #15)
- 修复弹幕录制出错 (issue #16)
- 弹幕文件统一礼物价格单位 (issue #18)
- Webhook 支持更多事件 (issue #19)
- 文件名重复自动加后缀 (issue #20)
- 记录免费礼物到弹幕文件为可选的
- 加强 api-key 的安全性
- 其它一些重构调整
## 1.3.2 ## 1.3.2
- 修复录制错误: `AssertionError: Invalid Tag` - 修复录制错误: `AssertionError: Invalid Tag`

19
Dockerfile Normal file
View 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
View 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"]

View File

@ -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
```
## 使用方法 ## 使用方法
### 使用默认设置文件和保存位置 ### 使用默认设置文件和保存位置

View File

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

View File

@ -103,7 +103,7 @@ class Application:
async def launch(self) -> None: async def launch(self) -> None:
self._setup() self._setup()
await self._task_manager.load_all_tasks() await self._task_manager.load_all_tasks()
logger.info('Launched Application') logger.info(f'Launched Application v{__version__}')
async def exit(self) -> None: async def exit(self) -> None:
await self._exit() await self._exit()
@ -136,50 +136,61 @@ class Application:
settings = await self._settings_manager.add_task_settings(room_id) settings = await self._settings_manager.add_task_settings(room_id)
await self._task_manager.add_task(settings) await self._task_manager.add_task(settings)
logger.info(f'Added task: {room_id}')
return room_id return room_id
async def remove_task(self, room_id: int) -> None: async def remove_task(self, room_id: int) -> None:
await self._task_manager.remove_task(room_id) await self._task_manager.remove_task(room_id)
await self._settings_manager.remove_task_settings(room_id) await self._settings_manager.remove_task_settings(room_id)
logger.info(f'Removed task: {room_id}')
async def remove_all_tasks(self) -> None: async def remove_all_tasks(self) -> None:
await self._task_manager.remove_all_tasks() await self._task_manager.remove_all_tasks()
await self._settings_manager.remove_all_task_settings() await self._settings_manager.remove_all_task_settings()
logger.info('Removed all tasks')
async def start_task(self, room_id: int) -> None: async def start_task(self, room_id: int) -> None:
await self._task_manager.start_task(room_id) await self._task_manager.start_task(room_id)
await self._settings_manager.mark_task_enabled(room_id) await self._settings_manager.mark_task_enabled(room_id)
logger.info(f'Started task: {room_id}')
async def stop_task(self, room_id: int, force: bool = False) -> None: async def stop_task(self, room_id: int, force: bool = False) -> None:
await self._task_manager.stop_task(room_id, force) await self._task_manager.stop_task(room_id, force)
await self._settings_manager.mark_task_disabled(room_id) await self._settings_manager.mark_task_disabled(room_id)
logger.info(f'Stopped task: {room_id}')
async def start_all_tasks(self) -> None: async def start_all_tasks(self) -> None:
await self._task_manager.start_all_tasks() await self._task_manager.start_all_tasks()
await self._settings_manager.mark_all_tasks_enabled() await self._settings_manager.mark_all_tasks_enabled()
logger.info('Started all tasks')
async def stop_all_tasks(self, force: bool = False) -> None: async def stop_all_tasks(self, force: bool = False) -> None:
await self._task_manager.stop_all_tasks(force) await self._task_manager.stop_all_tasks(force)
await self._settings_manager.mark_all_tasks_disabled() await self._settings_manager.mark_all_tasks_disabled()
logger.info('Stopped all tasks')
async def enable_task_recorder(self, room_id: int) -> None: async def enable_task_recorder(self, room_id: int) -> None:
await self._task_manager.enable_task_recorder(room_id) await self._task_manager.enable_task_recorder(room_id)
await self._settings_manager.mark_task_recorder_enabled(room_id) await self._settings_manager.mark_task_recorder_enabled(room_id)
logger.info(f'Enabled task recorder: {room_id}')
async def disable_task_recorder( async def disable_task_recorder(
self, room_id: int, force: bool = False self, room_id: int, force: bool = False
) -> None: ) -> None:
await self._task_manager.disable_task_recorder(room_id, force) await self._task_manager.disable_task_recorder(room_id, force)
await self._settings_manager.mark_task_recorder_disabled(room_id) await self._settings_manager.mark_task_recorder_disabled(room_id)
logger.info(f'Disabled task recorder: {room_id}')
async def enable_all_task_recorders(self) -> None: async def enable_all_task_recorders(self) -> None:
await self._task_manager.enable_all_task_recorders() await self._task_manager.enable_all_task_recorders()
await self._settings_manager.mark_all_task_recorders_enabled() await self._settings_manager.mark_all_task_recorders_enabled()
logger.info('Enabled all task recorders')
async def disable_all_task_recorders(self, force: bool = False) -> None: async def disable_all_task_recorders(self, force: bool = False) -> None:
await self._task_manager.disable_all_task_recorders(force) await self._task_manager.disable_all_task_recorders(force)
await self._settings_manager.mark_all_task_recorders_disabled() await self._settings_manager.mark_all_task_recorders_disabled()
logger.info('Disabled all task recorders')
def get_task_data(self, room_id: int) -> TaskData: def get_task_data(self, room_id: int) -> TaskData:
return self._task_manager.get_task_data(room_id) return self._task_manager.get_task_data(room_id)

View File

@ -68,11 +68,11 @@ class WebApi:
params = { params = {
'room_id': room_id, 'room_id': room_id,
'protocol': '0,1', 'protocol': '0,1',
'format': '0,2', 'format': '0,1,2',
'codec': '0,1', 'codec': '0,1',
'qn': qn, 'qn': qn,
'platform': 'web', 'platform': 'web',
'ptype': 16, 'ptype': 8,
} }
r = await self._get(self.GET_ROOM_PLAY_INFO_URL, params=params) r = await self._get(self.GET_ROOM_PLAY_INFO_URL, params=params)
return r['data'] return r['data']

View File

@ -2,7 +2,7 @@
import asyncio import asyncio
import re import re
import json import json
from typing import Dict, List, Optional, cast from typing import Dict, List, cast
import aiohttp import aiohttp
from tenacity import ( from tenacity import (
@ -165,9 +165,11 @@ class Live:
# the timestamp on the server at the moment in seconds # the timestamp on the server at the moment in seconds
return await self._api.get_timestamp() return await self._api.get_timestamp()
async def get_live_stream_url( async def get_live_stream_urls(
self, qn: QualityNumber = 10000, format: StreamFormat = 'flv' self,
) -> Optional[str]: qn: QualityNumber = 10000,
format: StreamFormat = 'flv',
) -> List[str]:
try: try:
data = await self._api.get_room_play_info(self._room_id, qn) data = await self._api.get_room_play_info(self._room_id, qn)
except Exception: except Exception:
@ -188,12 +190,13 @@ class Live:
accept_qn = cast(List[QualityNumber], codec['accept_qn']) accept_qn = cast(List[QualityNumber], codec['accept_qn'])
if qn not in accept_qn: if qn not in accept_qn:
return None return []
assert codec['current_qn'] == qn assert codec['current_qn'] == qn
url_info = codec['url_info'][0]
return url_info['host'] + codec['base_url'] + url_info['extra'] return [
i['host'] + codec['base_url'] + i['extra']
for i in codec['url_info']
]
def _check_room_play_info(self, data: ResponseData) -> None: def _check_room_play_info(self, data: ResponseData) -> None:
if data['is_hidden']: if data['is_hidden']:

View File

@ -15,6 +15,7 @@ QualityNumber = Literal[
StreamFormat = Literal[ StreamFormat = Literal[
'flv', 'flv',
'ts',
'fmp4', 'fmp4',
] ]

View File

@ -4,17 +4,21 @@ import logging
from contextlib import suppress from contextlib import suppress
from typing import Iterator, List, Optional from typing import Iterator, List, Optional
from blrec.core.models import GiftSendMsg, GuardBuyMsg, SuperChatMsg from tenacity import (
AsyncRetrying,
stop_after_attempt,
retry_if_not_exception_type,
)
from .. import __version__, __prog__, __github__ from .. import __version__, __prog__, __github__
from .danmaku_receiver import DanmakuReceiver, DanmuMsg from .danmaku_receiver import DanmakuReceiver, DanmuMsg
from .stream_recorder import StreamRecorder, StreamRecorderEventListener from .stream_recorder import StreamRecorder, StreamRecorderEventListener
from .statistics import StatisticsCalculator from .statistics import StatisticsCalculator
from ..bili.live import Live from ..bili.live import Live
from ..exception import exception_callback from ..exception import exception_callback, submit_exception
from ..event.event_emitter import EventListener, EventEmitter from ..event.event_emitter import EventListener, EventEmitter
from ..path import danmaku_path from ..path import danmaku_path
from ..core.models import GiftSendMsg, GuardBuyMsg, SuperChatMsg
from ..danmaku.models import ( from ..danmaku.models import (
Metadata, Danmu, GiftSendRecord, GuardBuyRecord, SuperChatRecord Metadata, Danmu, GiftSendRecord, GuardBuyRecord, SuperChatRecord
) )
@ -50,6 +54,7 @@ class DanmakuDumper(
*, *,
danmu_uname: bool = False, danmu_uname: bool = False,
record_gift_send: bool = False, record_gift_send: bool = False,
record_free_gifts: bool = False,
record_guard_buy: bool = False, record_guard_buy: bool = False,
record_super_chat: bool = False, record_super_chat: bool = False,
) -> None: ) -> None:
@ -61,6 +66,7 @@ class DanmakuDumper(
self.danmu_uname = danmu_uname self.danmu_uname = danmu_uname
self.record_gift_send = record_gift_send self.record_gift_send = record_gift_send
self.record_free_gifts = record_free_gifts
self.record_guard_buy = record_guard_buy self.record_guard_buy = record_guard_buy
self.record_super_chat = record_super_chat self.record_super_chat = record_super_chat
@ -124,7 +130,7 @@ class DanmakuDumper(
await self._cancel_dump_task() await self._cancel_dump_task()
def _create_dump_task(self) -> None: def _create_dump_task(self) -> None:
self._dump_task = asyncio.create_task(self._dump()) self._dump_task = asyncio.create_task(self._do_dump())
self._dump_task.add_done_callback(exception_callback) self._dump_task.add_done_callback(exception_callback)
async def _cancel_dump_task(self) -> None: async def _cancel_dump_task(self) -> None:
@ -133,7 +139,7 @@ class DanmakuDumper(
await self._dump_task await self._dump_task
@aio_task_with_room_id @aio_task_with_room_id
async def _dump(self) -> None: async def _do_dump(self) -> None:
assert self._path is not None assert self._path is not None
logger.debug('Started dumping danmaku') logger.debug('Started dumping danmaku')
self._calculator.reset() self._calculator.reset()
@ -144,6 +150,25 @@ class DanmakuDumper(
await self._emit('danmaku_file_created', self._path) await self._emit('danmaku_file_created', self._path)
await writer.write_metadata(self._make_metadata()) await writer.write_metadata(self._make_metadata())
async for attempt in AsyncRetrying(
retry=retry_if_not_exception_type((
asyncio.CancelledError
)),
stop=stop_after_attempt(3),
):
with attempt:
try:
await self._dumping_loop(writer)
except Exception as e:
submit_exception(e)
raise
finally:
logger.info(f"Danmaku file completed: '{self._path}'")
await self._emit('danmaku_file_completed', self._path)
logger.debug('Stopped dumping danmaku')
self._calculator.freeze()
async def _dumping_loop(self, writer: DanmakuWriter) -> None:
while True: while True:
msg = await self._receiver.get_message() msg = await self._receiver.get_message()
if isinstance(msg, DanmuMsg): if isinstance(msg, DanmuMsg):
@ -152,9 +177,13 @@ class DanmakuDumper(
elif isinstance(msg, GiftSendMsg): elif isinstance(msg, GiftSendMsg):
if not self.record_gift_send: if not self.record_gift_send:
continue continue
await writer.write_gift_send_record( record = self._make_gift_send_record(msg)
self._make_gift_send_record(msg) if (
) not self.record_free_gifts and
record.is_free_gift()
):
continue
await writer.write_gift_send_record(record)
elif isinstance(msg, GuardBuyMsg): elif isinstance(msg, GuardBuyMsg):
if not self.record_guard_buy: if not self.record_guard_buy:
continue continue
@ -169,11 +198,6 @@ class DanmakuDumper(
) )
else: else:
logger.warning('Unsupported message type:', repr(msg)) logger.warning('Unsupported message type:', repr(msg))
finally:
logger.info(f"Danmaku file completed: '{self._path}'")
await self._emit('danmaku_file_completed', self._path)
logger.debug('Stopped dumping danmaku')
self._calculator.freeze()
def _make_metadata(self) -> Metadata: def _make_metadata(self) -> Metadata:
return Metadata( return Metadata(
@ -235,7 +259,7 @@ class DanmakuDumper(
ts=self._calc_stime(msg.timestamp * 1000), ts=self._calc_stime(msg.timestamp * 1000),
uid=msg.uid, uid=msg.uid,
user=msg.uname, user=msg.uname,
price=msg.price, price=msg.price * msg.rate,
time=msg.time, time=msg.time,
message=msg.message, message=msg.message,
) )

View File

@ -92,6 +92,7 @@ class SuperChatMsg:
gift_name: str gift_name: str
count: int count: int
price: int price: int
rate: int
time: int # duration in seconds time: int # duration in seconds
message: str message: str
uid: int uid: int
@ -105,6 +106,7 @@ class SuperChatMsg:
gift_name=data['gift']['gift_name'], gift_name=data['gift']['gift_name'],
count=int(data['gift']['num']), count=int(data['gift']['num']),
price=int(data['price']), price=int(data['price']),
rate=int(data['rate']),
time=int(data['time']), time=int(data['time']),
message=data['message'], message=data['message'],
uid=int(data['uid']), uid=int(data['uid']),

View File

@ -4,22 +4,41 @@ import logging
from contextlib import suppress from contextlib import suppress
import aiofiles import aiofiles
from aiofiles.threadpool.text import AsyncTextIOWrapper
from tenacity import (
AsyncRetrying,
stop_after_attempt,
retry_if_not_exception_type,
)
from .raw_danmaku_receiver import RawDanmakuReceiver from .raw_danmaku_receiver import RawDanmakuReceiver
from .stream_recorder import StreamRecorder, StreamRecorderEventListener from .stream_recorder import StreamRecorder, StreamRecorderEventListener
from ..exception import exception_callback from ..exception import exception_callback, submit_exception
from ..event.event_emitter import EventListener, EventEmitter
from ..path import raw_danmaku_path from ..path import raw_danmaku_path
from ..utils.mixins import SwitchableMixin from ..utils.mixins import SwitchableMixin
from ..logging.room_id import aio_task_with_room_id from ..logging.room_id import aio_task_with_room_id
__all__ = 'RawDanmakuDumper', __all__ = 'RawDanmakuDumper', 'RawDanmakuDumperEventListener'
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class RawDanmakuDumper(StreamRecorderEventListener, SwitchableMixin): class RawDanmakuDumperEventListener(EventListener):
async def on_raw_danmaku_file_created(self, path: str) -> None:
...
async def on_raw_danmaku_file_completed(self, path: str) -> None:
...
class RawDanmakuDumper(
EventEmitter[RawDanmakuDumperEventListener],
StreamRecorderEventListener,
SwitchableMixin,
):
def __init__( def __init__(
self, self,
stream_recorder: StreamRecorder, stream_recorder: StreamRecorder,
@ -53,7 +72,7 @@ class RawDanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
await self._cancel_dump_task() await self._cancel_dump_task()
def _create_dump_task(self) -> None: def _create_dump_task(self) -> None:
self._dump_task = asyncio.create_task(self._dump()) self._dump_task = asyncio.create_task(self._do_dump())
self._dump_task.add_done_callback(exception_callback) self._dump_task.add_done_callback(exception_callback)
async def _cancel_dump_task(self) -> None: async def _cancel_dump_task(self) -> None:
@ -62,16 +81,36 @@ class RawDanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
await self._dump_task await self._dump_task
@aio_task_with_room_id @aio_task_with_room_id
async def _dump(self) -> None: async def _do_dump(self) -> None:
logger.debug('Started dumping raw danmaku') logger.debug('Started dumping raw danmaku')
try: try:
async with aiofiles.open(self._path, 'wt', encoding='utf8') as f: async with aiofiles.open(self._path, 'wt', encoding='utf8') as f:
logger.info(f"Raw danmaku file created: '{self._path}'") logger.info(f"Raw danmaku file created: '{self._path}'")
await self._emit('raw_danmaku_file_created', self._path)
async for attempt in AsyncRetrying(
retry=retry_if_not_exception_type((
asyncio.CancelledError
)),
stop=stop_after_attempt(3),
):
with attempt:
try:
await self._dumping_loop(f)
except Exception as e:
submit_exception(e)
raise
while True: while True:
danmu = await self._receiver.get_raw_danmaku() danmu = await self._receiver.get_raw_danmaku()
json_string = json.dumps(danmu, ensure_ascii=False) json_string = json.dumps(danmu, ensure_ascii=False)
await f.write(json_string + '\n') await f.write(json_string + '\n')
finally: finally:
logger.info(f"Raw danmaku file completed: '{self._path}'") logger.info(f"Raw danmaku file completed: '{self._path}'")
await self._emit('raw_danmaku_file_completed', self._path)
logger.debug('Stopped dumping raw danmaku') logger.debug('Stopped dumping raw danmaku')
async def _dumping_loop(self, file: AsyncTextIOWrapper) -> None:
while True:
danmu = await self._receiver.get_raw_danmaku()
json_string = json.dumps(danmu, ensure_ascii=False)
await file.write(json_string + '\n')

View File

@ -11,7 +11,7 @@ from tenacity import retry, wait_fixed, stop_after_attempt
from .danmaku_receiver import DanmakuReceiver from .danmaku_receiver import DanmakuReceiver
from .danmaku_dumper import DanmakuDumper, DanmakuDumperEventListener from .danmaku_dumper import DanmakuDumper, DanmakuDumperEventListener
from .raw_danmaku_receiver import RawDanmakuReceiver from .raw_danmaku_receiver import RawDanmakuReceiver
from .raw_danmaku_dumper import RawDanmakuDumper from .raw_danmaku_dumper import RawDanmakuDumper, RawDanmakuDumperEventListener
from .stream_recorder import StreamRecorder, StreamRecorderEventListener from .stream_recorder import StreamRecorder, StreamRecorderEventListener
from ..event.event_emitter import EventListener, EventEmitter from ..event.event_emitter import EventListener, EventEmitter
from ..bili.live import Live from ..bili.live import Live
@ -41,17 +41,33 @@ class RecorderEventListener(EventListener):
... ...
async def on_video_file_created( async def on_video_file_created(
self, path: str, record_start_time: int self, recorder: Recorder, path: str
) -> None: ) -> None:
... ...
async def on_video_file_completed(self, path: str) -> None: async def on_video_file_completed(
self, recorder: Recorder, path: str
) -> None:
... ...
async def on_danmaku_file_created(self, path: str) -> None: async def on_danmaku_file_created(
self, recorder: Recorder, path: str
) -> None:
... ...
async def on_danmaku_file_completed(self, path: str) -> None: async def on_danmaku_file_completed(
self, recorder: Recorder, path: str
) -> None:
...
async def on_raw_danmaku_file_created(
self, recorder: Recorder, path: str
) -> None:
...
async def on_raw_danmaku_file_completed(
self, recorder: Recorder, path: str
) -> None:
... ...
@ -60,6 +76,7 @@ class Recorder(
LiveEventListener, LiveEventListener,
AsyncStoppableMixin, AsyncStoppableMixin,
DanmakuDumperEventListener, DanmakuDumperEventListener,
RawDanmakuDumperEventListener,
StreamRecorderEventListener, StreamRecorderEventListener,
): ):
def __init__( def __init__(
@ -75,6 +92,7 @@ class Recorder(
disconnection_timeout: Optional[int] = None, disconnection_timeout: Optional[int] = None,
danmu_uname: bool = False, danmu_uname: bool = False,
record_gift_send: bool = False, record_gift_send: bool = False,
record_free_gifts: bool = False,
record_guard_buy: bool = False, record_guard_buy: bool = False,
record_super_chat: bool = False, record_super_chat: bool = False,
save_cover: bool = False, save_cover: bool = False,
@ -110,6 +128,7 @@ class Recorder(
self._danmaku_receiver, self._danmaku_receiver,
danmu_uname=danmu_uname, danmu_uname=danmu_uname,
record_gift_send=record_gift_send, record_gift_send=record_gift_send,
record_free_gifts=record_free_gifts,
record_guard_buy=record_guard_buy, record_guard_buy=record_guard_buy,
record_super_chat=record_super_chat, record_super_chat=record_super_chat,
) )
@ -119,6 +138,10 @@ class Recorder(
self._raw_danmaku_receiver, self._raw_danmaku_receiver,
) )
@property
def live(self) -> Live:
return self._live
@property @property
def recording(self) -> bool: def recording(self) -> bool:
return self._recording return self._recording
@ -175,6 +198,14 @@ class Recorder(
def record_gift_send(self, value: bool) -> None: def record_gift_send(self, value: bool) -> None:
self._danmaku_dumper.record_gift_send = value self._danmaku_dumper.record_gift_send = value
@property
def record_free_gifts(self) -> bool:
return self._danmaku_dumper.record_free_gifts
@record_free_gifts.setter
def record_free_gifts(self, value: bool) -> None:
self._danmaku_dumper.record_free_gifts = value
@property @property
def record_guard_buy(self) -> bool: def record_guard_buy(self) -> bool:
return self._danmaku_dumper.record_guard_buy return self._danmaku_dumper.record_guard_buy
@ -246,6 +277,7 @@ class Recorder(
async def _do_start(self) -> None: async def _do_start(self) -> None:
self._live_monitor.add_listener(self) self._live_monitor.add_listener(self)
self._danmaku_dumper.add_listener(self) self._danmaku_dumper.add_listener(self)
self._raw_danmaku_dumper.add_listener(self)
self._stream_recorder.add_listener(self) self._stream_recorder.add_listener(self)
logger.debug('Started recorder') logger.debug('Started recorder')
@ -259,6 +291,7 @@ class Recorder(
await self._stop_recording() await self._stop_recording()
self._live_monitor.remove_listener(self) self._live_monitor.remove_listener(self)
self._danmaku_dumper.remove_listener(self) self._danmaku_dumper.remove_listener(self)
self._raw_danmaku_dumper.remove_listener(self)
self._stream_recorder.remove_listener(self) self._stream_recorder.remove_listener(self)
logger.debug('Stopped recorder') logger.debug('Stopped recorder')
@ -306,18 +339,24 @@ class Recorder(
async def on_video_file_created( async def on_video_file_created(
self, path: str, record_start_time: int self, path: str, record_start_time: int
) -> None: ) -> None:
await self._emit('video_file_created', path, record_start_time) await self._emit('video_file_created', self, path)
async def on_video_file_completed(self, path: str) -> None: async def on_video_file_completed(self, path: str) -> None:
await self._emit('video_file_completed', path) await self._emit('video_file_completed', self, path)
if self.save_cover: if self.save_cover:
await self._save_cover_image(path) await self._save_cover_image(path)
async def on_danmaku_file_created(self, path: str) -> None: async def on_danmaku_file_created(self, path: str) -> None:
await self._emit('danmaku_file_created', path) await self._emit('danmaku_file_created', self, path)
async def on_danmaku_file_completed(self, path: str) -> None: async def on_danmaku_file_completed(self, path: str) -> None:
await self._emit('danmaku_file_completed', path) await self._emit('danmaku_file_completed', self, path)
async def on_raw_danmaku_file_created(self, path: str) -> None:
await self._emit('raw_danmaku_file_created', self, path)
async def on_raw_danmaku_file_completed(self, path: str) -> None:
await self._emit('raw_danmaku_file_completed', self, path)
async def on_stream_recording_stopped(self) -> None: async def on_stream_recording_stopped(self) -> None:
logger.debug('Stream recording stopped') logger.debug('Stream recording stopped')

View File

@ -1,5 +1,6 @@
import io import io
import os import os
import re
import time import time
import errno import errno
import asyncio import asyncio
@ -40,7 +41,7 @@ from ..bili.typing import QualityNumber
from ..flv.stream_processor import StreamProcessor, BaseOutputFileManager from ..flv.stream_processor import StreamProcessor, BaseOutputFileManager
from ..utils.mixins import AsyncCooperationMix, AsyncStoppableMixin from ..utils.mixins import AsyncCooperationMix, AsyncStoppableMixin
from ..path import escape_path from ..path import escape_path
from ..flv.exceptions import FlvStreamCorruptedError from ..flv.exceptions import FlvDataError, FlvStreamCorruptedError
from ..bili.exceptions import ( from ..bili.exceptions import (
LiveRoomHidden, LiveRoomLocked, LiveRoomEncrypted, NoStreamUrlAvailable LiveRoomHidden, LiveRoomLocked, LiveRoomEncrypted, NoStreamUrlAvailable
) )
@ -224,6 +225,7 @@ class StreamRecorder(
def _run(self) -> None: def _run(self) -> None:
self._calculator.reset() self._calculator.reset()
self._use_candidate_stream: bool = False
try: try:
with tqdm( with tqdm(
desc='Recording', desc='Recording',
@ -297,6 +299,7 @@ class StreamRecorder(
self._stopped = True self._stopped = True
except Exception as e: except Exception as e:
logger.exception(e) logger.exception(e)
self._handle_exception(e)
raise raise
def _streaming_loop(self) -> None: def _streaming_loop(self) -> None:
@ -333,6 +336,10 @@ class StreamRecorder(
self._stopped = True self._stopped = True
else: else:
logger.debug('Connection recovered') logger.debug('Connection recovered')
except FlvDataError as e:
logger.warning(repr(e))
self._use_candidate_stream = not self._use_candidate_stream
url = self._get_live_stream_url()
except FlvStreamCorruptedError as e: except FlvStreamCorruptedError as e:
logger.warning(repr(e)) logger.warning(repr(e))
url = self._get_live_stream_url() url = self._get_live_stream_url()
@ -368,10 +375,14 @@ class StreamRecorder(
) )
def _get_live_stream_url(self) -> str: def _get_live_stream_url(self) -> str:
qn = self._real_quality_number or self.quality_number qn = self._real_quality_number or self.quality_number
url = self._run_coroutine(self._live.get_live_stream_url(qn, 'flv')) logger.debug(
'Getting the live stream url... '
f'qn: {qn}, use_candidate_stream: {self._use_candidate_stream}'
)
urls = self._run_coroutine(self._live.get_live_stream_urls(qn, 'flv'))
if self._real_quality_number is None: if self._real_quality_number is None:
if url is None: if not urls:
logger.info( logger.info(
f'The specified video quality ({qn}) is not available, ' f'The specified video quality ({qn}) is not available, '
'using the original video quality (10000) instead.' 'using the original video quality (10000) instead.'
@ -382,7 +393,17 @@ class StreamRecorder(
logger.info(f'The specified video quality ({qn}) is available') logger.info(f'The specified video quality ({qn}) is available')
self._real_quality_number = self.quality_number self._real_quality_number = self.quality_number
assert url is not None if not self._use_candidate_stream:
url = urls[0]
else:
try:
url = urls[1]
except IndexError:
logger.debug(
'no candidate stream url available, '
'using the primary stream url instead.'
)
url = urls[0]
logger.debug(f"Got live stream url: '{url}'") logger.debug(f"Got live stream url: '{url}'")
return url return url
@ -545,9 +566,17 @@ class OutputFileManager(BaseOutputFileManager, AsyncCooperationMix):
second=str(date_time.second).rjust(2, '0'), second=str(date_time.second).rjust(2, '0'),
) )
full_pathname = os.path.abspath( pathname = os.path.abspath(
os.path.expanduser(os.path.join(self.out_dir, relpath) + '.flv') os.path.expanduser(os.path.join(self.out_dir, relpath) + '.flv')
) )
os.makedirs(os.path.dirname(full_pathname), exist_ok=True) os.makedirs(os.path.dirname(pathname), exist_ok=True)
while os.path.exists(pathname):
root, ext = os.path.splitext(pathname)
m = re.search(r'_\((\d+)\)$', root)
if m is None:
root += '_(1)'
else:
root = re.sub(r'\(\d+\)$', f'({int(m.group(1)) + 1})', root)
pathname = root + ext
return full_pathname return pathname

View File

@ -156,13 +156,22 @@ class DanmakuWriter:
'uid': str(dm.uid), 'uid': str(dm.uid),
'user': dm.uname, 'user': dm.uname,
} }
try:
elem = etree.Element('d', attrib=attrib) elem = etree.Element('d', attrib=attrib)
except ValueError:
# ValueError: All strings must be XML compatible: Unicode or ASCII,
# no NULL bytes or control characters
attrib['user'] = remove_control_characters(dm.uname)
elem = etree.Element('d', attrib=attrib)
try: try:
elem.text = dm.text elem.text = dm.text
except ValueError: except ValueError:
# ValueError: All strings must be XML compatible: Unicode or ASCII, # ValueError: All strings must be XML compatible: Unicode or ASCII,
# no NULL bytes or control characters # no NULL bytes or control characters
elem.text = remove_control_characters(dm.text) elem.text = remove_control_characters(dm.text)
return ' ' + etree.tostring(elem, encoding='utf8').decode() + '\n' return ' ' + etree.tostring(elem, encoding='utf8').decode() + '\n'
def _serialize_gift_send_record(self, record: GiftSendRecord) -> str: def _serialize_gift_send_record(self, record: GiftSendRecord) -> str:

View File

@ -40,6 +40,9 @@ class GiftSendRecord:
cointype: Literal['sliver', 'gold'] cointype: Literal['sliver', 'gold']
price: int price: int
def is_free_gift(self) -> bool:
return self.cointype != 'gold'
@attr.s(auto_attribs=True, slots=True, frozen=True) @attr.s(auto_attribs=True, slots=True, frozen=True)
class GuardBuyRecord: class GuardBuyRecord:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -10,6 +10,6 @@
<body> <body>
<app-root></app-root> <app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript> <noscript>Please enable JavaScript to continue using this application.</noscript>
<script src="runtime.c48b962c8225f379.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.7a4da5a70e652c3f.js" type="module"></script> <script src="runtime.0c70df55750c11ae.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.5e1124f1a8c47971.js" type="module"></script>
</body></html> </body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{ {
"configVersion": 1, "configVersion": 1,
"timestamp": 1642737806856, "timestamp": 1644635235127,
"index": "/index.html", "index": "/index.html",
"assetGroups": [ "assetGroups": [
{ {
@ -11,17 +11,17 @@
"ignoreVary": true "ignoreVary": true
}, },
"urls": [ "urls": [
"/103.9c8251484169c949.js", "/103.5b5d2a6e5a8a7479.js",
"/146.92e3b29c4c754544.js", "/146.92e3b29c4c754544.js",
"/622.dd6c6ac77555edc7.js", "/622.03823b1714105423.js",
"/66.97582e026891bf70.js", "/66.97582e026891bf70.js",
"/853.5697121b2e654d67.js", "/853.84ee7e1d7cff8913.js",
"/common.858f777e9296e6f2.js", "/common.858f777e9296e6f2.js",
"/index.html", "/index.html",
"/main.7a4da5a70e652c3f.js", "/main.5e1124f1a8c47971.js",
"/manifest.webmanifest", "/manifest.webmanifest",
"/polyfills.4b08448aee19bb22.js", "/polyfills.4b08448aee19bb22.js",
"/runtime.c48b962c8225f379.js", "/runtime.0c70df55750c11ae.js",
"/styles.1f581691b230dc4d.css" "/styles.1f581691b230dc4d.css"
], ],
"patterns": [] "patterns": []
@ -1633,11 +1633,11 @@
], ],
"dataGroups": [], "dataGroups": [],
"hashTable": { "hashTable": {
"/103.9c8251484169c949.js": "b111521f577092144d65506fa72c121543fd4446", "/103.5b5d2a6e5a8a7479.js": "cc0240f217015b6d4ddcc14f31fcc42e1c1c282a",
"/146.92e3b29c4c754544.js": "3824de681dd1f982ea69a065cdf54d7a1e781f4d", "/146.92e3b29c4c754544.js": "3824de681dd1f982ea69a065cdf54d7a1e781f4d",
"/622.dd6c6ac77555edc7.js": "5406594e418982532e138bf3c12ccbb1cfb09942", "/622.03823b1714105423.js": "86c61c37b53c951370ef2a16eb187cda666d7562",
"/66.97582e026891bf70.js": "11cfd8acd3399fef42f0cf77d64aafc62c7e6994", "/66.97582e026891bf70.js": "11cfd8acd3399fef42f0cf77d64aafc62c7e6994",
"/853.5697121b2e654d67.js": "beb26b99743f6363c59c477b18be790b1e70d56e", "/853.84ee7e1d7cff8913.js": "6281853ef474fc543ac39fb47ec4a0a61ca875fa",
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1", "/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1", "/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
"/assets/fill/.gitkeep": "da39a3ee5e6b4b0d3255bfef95601890afd80709", "/assets/fill/.gitkeep": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
@ -3232,11 +3232,11 @@
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01", "/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068", "/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
"/common.858f777e9296e6f2.js": "b68ca68e1e214a2537d96935c23410126cc564dd", "/common.858f777e9296e6f2.js": "b68ca68e1e214a2537d96935c23410126cc564dd",
"/index.html": "0ed90c4296321165a7358a99ae67f911cc8b8a0f", "/index.html": "9e332051ad11197ce464047d2a13bc15016a3d70",
"/main.7a4da5a70e652c3f.js": "6dd73cbf2d5aee6a29559f6357b1075aadd6fb99", "/main.5e1124f1a8c47971.js": "325ed4fbaa0160c18e06bb646431d1fa86f826f1",
"/manifest.webmanifest": "0c4534b4c868d756691b1b4372cecb2efce47c6d", "/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d", "/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
"/runtime.c48b962c8225f379.js": "ae674dc840fe5aa957d08253f56e21c4b772b93b", "/runtime.0c70df55750c11ae.js": "8e2f23cde1c56d8859adf4cd948753c1f8736d86",
"/styles.1f581691b230dc4d.css": "6f5befbbad57c2b2e80aae855139744b8010d150" "/styles.1f581691b230dc4d.css": "6f5befbbad57c2b2e80aae855139744b8010d150"
}, },
"navigationUrls": [ "navigationUrls": [

View File

@ -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))})()})();

View File

@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
class SpaceReclaimer(SpaceEventListener, SwitchableMixin): class SpaceReclaimer(SpaceEventListener, SwitchableMixin):
_SUFFIX_SET = frozenset(('.flv', '.mp4', '.xml', '.meta')) _SUFFIX_SET = frozenset(('.flv', '.mp4', '.xml', '.jsonl', '.jpg'))
def __init__( def __init__(
self, self,

View File

@ -9,10 +9,28 @@ from .models import (
LiveEndedEventData, LiveEndedEventData,
RoomChangeEvent, RoomChangeEvent,
RoomChangeEventData, RoomChangeEventData,
RecordingStartedEvent,
RecordingStartedEventData,
RecordingFinishedEvent,
RecordingFinishedEventData,
RecordingCancelledEvent,
RecordingCancelledEventData,
VideoFileCreatedEvent,
VideoFileCreatedEventData,
VideoFileCompletedEvent,
VideoFileCompletedEventData,
DanmakuFileCreatedEvent,
DanmakuFileCreatedEventData,
DanmakuFileCompletedEvent,
DanmakuFileCompletedEventData,
RawDanmakuFileCreatedEvent,
RawDanmakuFileCreatedEventData,
RawDanmakuFileCompletedEvent,
RawDanmakuFileCompletedEventData,
VideoPostprocessingCompletedEvent,
VideoPostprocessingCompletedEventData,
SpaceNoEnoughEvent, SpaceNoEnoughEvent,
SpaceNoEnoughEventData, SpaceNoEnoughEventData,
FileCompletedEvent,
FileCompletedEventData,
Error, Error,
ErrorData, ErrorData,
) )
@ -31,10 +49,28 @@ __all__ = (
'LiveEndedEventData', 'LiveEndedEventData',
'RoomChangeEvent', 'RoomChangeEvent',
'RoomChangeEventData', 'RoomChangeEventData',
'RecordingStartedEvent',
'RecordingStartedEventData',
'RecordingFinishedEvent',
'RecordingFinishedEventData',
'RecordingCancelledEvent',
'RecordingCancelledEventData',
'VideoFileCreatedEvent',
'VideoFileCreatedEventData',
'VideoFileCompletedEvent',
'VideoFileCompletedEventData',
'DanmakuFileCreatedEvent',
'DanmakuFileCreatedEventData',
'DanmakuFileCompletedEvent',
'DanmakuFileCompletedEventData',
'RawDanmakuFileCreatedEvent',
'RawDanmakuFileCreatedEventData',
'RawDanmakuFileCompletedEvent',
'RawDanmakuFileCompletedEventData',
'VideoPostprocessingCompletedEvent',
'VideoPostprocessingCompletedEventData',
'SpaceNoEnoughEvent', 'SpaceNoEnoughEvent',
'SpaceNoEnoughEventData', 'SpaceNoEnoughEventData',
'FileCompletedEvent',
'FileCompletedEventData',
'Error', 'Error',
'ErrorData', 'ErrorData',
) )

View File

@ -9,18 +9,38 @@ from .models import (
LiveEndedEventData, LiveEndedEventData,
RoomChangeEvent, RoomChangeEvent,
RoomChangeEventData, RoomChangeEventData,
FileCompletedEvent, RecordingStartedEvent,
FileCompletedEventData, RecordingStartedEventData,
RecordingFinishedEvent,
RecordingFinishedEventData,
RecordingCancelledEvent,
RecordingCancelledEventData,
VideoFileCreatedEvent,
VideoFileCreatedEventData,
VideoFileCompletedEvent,
VideoFileCompletedEventData,
DanmakuFileCreatedEvent,
DanmakuFileCreatedEventData,
DanmakuFileCompletedEvent,
DanmakuFileCompletedEventData,
RawDanmakuFileCreatedEvent,
RawDanmakuFileCreatedEventData,
RawDanmakuFileCompletedEvent,
RawDanmakuFileCompletedEventData,
VideoPostprocessingCompletedEvent,
VideoPostprocessingCompletedEventData,
SpaceNoEnoughEvent, SpaceNoEnoughEvent,
SpaceNoEnoughEventData, SpaceNoEnoughEventData,
) )
from ..bili.live import Live from ..bili.live import Live
from ..bili.models import RoomInfo from ..bili.models import RoomInfo
from ..bili.live_monitor import LiveEventListener from ..bili.live_monitor import LiveEventListener
from ..core.recorder import RecorderEventListener
from ..disk_space import SpaceEventListener from ..disk_space import SpaceEventListener
from ..postprocess import PostprocessorEventListener from ..postprocess import PostprocessorEventListener
if TYPE_CHECKING: if TYPE_CHECKING:
from ..bili.live_monitor import LiveMonitor from ..bili.live_monitor import LiveMonitor
from ..core.recorder import Recorder
from ..disk_space import SpaceMonitor, DiskUsage from ..disk_space import SpaceMonitor, DiskUsage
from ..postprocess import Postprocessor from ..postprocess import Postprocessor
@ -52,6 +72,74 @@ class LiveEventSubmitter(LiveEventListener):
event_center.submit(RoomChangeEvent.from_data(data)) event_center.submit(RoomChangeEvent.from_data(data))
class RecorderEventSubmitter(RecorderEventListener):
def __init__(self, recorder: Recorder) -> None:
super().__init__()
recorder.add_listener(self)
async def on_recording_started(self, recorder: Recorder) -> None:
data = RecordingStartedEventData(recorder.live.room_info)
event_center.submit(RecordingStartedEvent.from_data(data))
async def on_recording_finished(self, recorder: Recorder) -> None:
data = RecordingFinishedEventData(recorder.live.room_info)
event_center.submit(RecordingFinishedEvent.from_data(data))
async def on_recording_cancelled(self, recorder: Recorder) -> None:
data = RecordingCancelledEventData(recorder.live.room_info)
event_center.submit(RecordingCancelledEvent.from_data(data))
async def on_video_file_created(
self, recorder: Recorder, path: str
) -> None:
data = VideoFileCreatedEventData(recorder.live.room_id, path)
event_center.submit(VideoFileCreatedEvent.from_data(data))
async def on_video_file_completed(
self, recorder: Recorder, path: str
) -> None:
data = VideoFileCompletedEventData(recorder.live.room_id, path)
event_center.submit(VideoFileCompletedEvent.from_data(data))
async def on_danmaku_file_created(
self, recorder: Recorder, path: str
) -> None:
data = DanmakuFileCreatedEventData(recorder.live.room_id, path)
event_center.submit(DanmakuFileCreatedEvent.from_data(data))
async def on_danmaku_file_completed(
self, recorder: Recorder, path: str
) -> None:
data = DanmakuFileCompletedEventData(recorder.live.room_id, path)
event_center.submit(DanmakuFileCompletedEvent.from_data(data))
async def on_raw_danmaku_file_created(
self, recorder: Recorder, path: str
) -> None:
data = RawDanmakuFileCreatedEventData(recorder.live.room_id, path)
event_center.submit(RawDanmakuFileCreatedEvent.from_data(data))
async def on_raw_danmaku_file_completed(
self, recorder: Recorder, path: str
) -> None:
data = RawDanmakuFileCompletedEventData(recorder.live.room_id, path)
event_center.submit(RawDanmakuFileCompletedEvent.from_data(data))
class PostprocessorEventSubmitter(PostprocessorEventListener):
def __init__(self, postprocessor: Postprocessor) -> None:
super().__init__()
postprocessor.add_listener(self)
async def on_video_postprocessing_completed(
self, postprocessor: Postprocessor, path: str
) -> None:
data = VideoPostprocessingCompletedEventData(
postprocessor.recorder.live.room_id, path
)
event_center.submit(VideoPostprocessingCompletedEvent.from_data(data))
class SpaceEventSubmitter(SpaceEventListener): class SpaceEventSubmitter(SpaceEventListener):
def __init__(self, space_monitor: SpaceMonitor) -> None: def __init__(self, space_monitor: SpaceMonitor) -> None:
super().__init__() super().__init__()
@ -62,13 +150,3 @@ class SpaceEventSubmitter(SpaceEventListener):
) -> None: ) -> None:
data = SpaceNoEnoughEventData(path, threshold, disk_usage) data = SpaceNoEnoughEventData(path, threshold, disk_usage)
event_center.submit(SpaceNoEnoughEvent.from_data(data)) event_center.submit(SpaceNoEnoughEvent.from_data(data))
class PostprocessorEventSubmitter(PostprocessorEventListener):
def __init__(self, postprocessor: Postprocessor) -> None:
super().__init__()
postprocessor.add_listener(self)
async def on_file_completed(self, room_id: int, path: str) -> None:
data = FileCompletedEventData(room_id, path)
event_center.submit(FileCompletedEvent.from_data(data))

View File

@ -9,6 +9,7 @@ import attr
if TYPE_CHECKING: if TYPE_CHECKING:
from ..bili.models import UserInfo, RoomInfo from ..bili.models import UserInfo, RoomInfo
from ..disk_space.space_monitor import DiskUsage from ..disk_space.space_monitor import DiskUsage
from ..exception import format_exception
_D = TypeVar('_D', bound='BaseEventData') _D = TypeVar('_D', bound='BaseEventData')
@ -80,6 +81,127 @@ class RoomChangeEvent(BaseEvent[RoomChangeEventData]):
data: RoomChangeEventData data: RoomChangeEventData
@attr.s(auto_attribs=True, slots=True, frozen=True)
class RecordingStartedEventData(BaseEventData):
room_info: RoomInfo
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
class RecordingStartedEvent(BaseEvent[RecordingStartedEventData]):
type: str = 'RecordingStartedEvent'
data: RecordingStartedEventData
@attr.s(auto_attribs=True, slots=True, frozen=True)
class RecordingFinishedEventData(BaseEventData):
room_info: RoomInfo
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
class RecordingFinishedEvent(BaseEvent[RecordingFinishedEventData]):
type: str = 'RecordingFinishedEvent'
data: RecordingFinishedEventData
@attr.s(auto_attribs=True, slots=True, frozen=True)
class RecordingCancelledEventData(BaseEventData):
room_info: RoomInfo
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
class RecordingCancelledEvent(BaseEvent[RecordingCancelledEventData]):
type: str = 'RecordingCancelledEvent'
data: RecordingCancelledEventData
@attr.s(auto_attribs=True, slots=True, frozen=True)
class VideoFileCreatedEventData(BaseEventData):
room_id: int
path: str
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
class VideoFileCreatedEvent(BaseEvent[VideoFileCreatedEventData]):
type: str = 'VideoFileCreatedEvent'
data: VideoFileCreatedEventData
@attr.s(auto_attribs=True, slots=True, frozen=True)
class VideoFileCompletedEventData(BaseEventData):
room_id: int
path: str
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
class VideoFileCompletedEvent(BaseEvent[VideoFileCompletedEventData]):
type: str = 'VideoFileCompletedEvent'
data: VideoFileCompletedEventData
@attr.s(auto_attribs=True, slots=True, frozen=True)
class DanmakuFileCreatedEventData(BaseEventData):
room_id: int
path: str
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
class DanmakuFileCreatedEvent(BaseEvent[DanmakuFileCreatedEventData]):
type: str = 'DanmakuFileCreatedEvent'
data: DanmakuFileCreatedEventData
@attr.s(auto_attribs=True, slots=True, frozen=True)
class DanmakuFileCompletedEventData(BaseEventData):
room_id: int
path: str
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
class DanmakuFileCompletedEvent(BaseEvent[DanmakuFileCompletedEventData]):
type: str = 'DanmakuFileCompletedEvent'
data: DanmakuFileCompletedEventData
@attr.s(auto_attribs=True, slots=True, frozen=True)
class RawDanmakuFileCreatedEventData(BaseEventData):
room_id: int
path: str
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
class RawDanmakuFileCreatedEvent(BaseEvent[RawDanmakuFileCreatedEventData]):
type: str = 'RawDanmakuFileCreatedEvent'
data: RawDanmakuFileCreatedEventData
@attr.s(auto_attribs=True, slots=True, frozen=True)
class RawDanmakuFileCompletedEventData(BaseEventData):
room_id: int
path: str
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
class RawDanmakuFileCompletedEvent(
BaseEvent[RawDanmakuFileCompletedEventData]
):
type: str = 'RawDanmakuFileCompletedEvent'
data: RawDanmakuFileCompletedEventData
@attr.s(auto_attribs=True, slots=True, frozen=True)
class VideoPostprocessingCompletedEventData(BaseEventData):
room_id: int
path: str
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
class VideoPostprocessingCompletedEvent(
BaseEvent[VideoPostprocessingCompletedEventData]
):
type: str = 'VideoPostprocessingCompletedEvent'
data: VideoPostprocessingCompletedEventData
@attr.s(auto_attribs=True, slots=True, frozen=True) @attr.s(auto_attribs=True, slots=True, frozen=True)
class SpaceNoEnoughEventData(BaseEventData): class SpaceNoEnoughEventData(BaseEventData):
path: str path: str
@ -93,23 +215,17 @@ class SpaceNoEnoughEvent(BaseEvent[SpaceNoEnoughEventData]):
data: SpaceNoEnoughEventData data: SpaceNoEnoughEventData
@attr.s(auto_attribs=True, slots=True, frozen=True)
class FileCompletedEventData(BaseEventData):
room_id: int
path: str
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
class FileCompletedEvent(BaseEvent[FileCompletedEventData]):
type: str = 'FileCompletedEvent'
data: FileCompletedEventData
@attr.s(auto_attribs=True, slots=True, frozen=True) @attr.s(auto_attribs=True, slots=True, frozen=True)
class ErrorData(BaseEventData): class ErrorData(BaseEventData):
name: str name: str
detail: str detail: str
@classmethod
def from_exc(cls, exc: BaseException) -> ErrorData:
return cls(
name=type(exc).__name__, detail=format_exception(exc),
)
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True) @attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
class Error(BaseEvent[ErrorData]): class Error(BaseEvent[ErrorData]):

View File

@ -8,10 +8,28 @@ from .models import (
LiveEndedEventData, LiveEndedEventData,
RoomChangeEvent, RoomChangeEvent,
RoomChangeEventData, RoomChangeEventData,
RecordingStartedEvent,
RecordingStartedEventData,
RecordingFinishedEvent,
RecordingFinishedEventData,
RecordingCancelledEvent,
RecordingCancelledEventData,
VideoFileCreatedEvent,
VideoFileCreatedEventData,
VideoFileCompletedEvent,
VideoFileCompletedEventData,
DanmakuFileCreatedEvent,
DanmakuFileCreatedEventData,
DanmakuFileCompletedEvent,
DanmakuFileCompletedEventData,
RawDanmakuFileCreatedEvent,
RawDanmakuFileCreatedEventData,
RawDanmakuFileCompletedEvent,
RawDanmakuFileCompletedEventData,
VideoPostprocessingCompletedEvent,
VideoPostprocessingCompletedEventData,
SpaceNoEnoughEvent, SpaceNoEnoughEvent,
SpaceNoEnoughEventData, SpaceNoEnoughEventData,
FileCompletedEvent,
FileCompletedEventData,
Error, Error,
ErrorData, ErrorData,
) )
@ -21,8 +39,17 @@ Event = Union[
LiveBeganEvent, LiveBeganEvent,
LiveEndedEvent, LiveEndedEvent,
RoomChangeEvent, RoomChangeEvent,
RecordingStartedEvent,
RecordingFinishedEvent,
RecordingCancelledEvent,
VideoFileCreatedEvent,
VideoFileCompletedEvent,
DanmakuFileCreatedEvent,
DanmakuFileCompletedEvent,
RawDanmakuFileCreatedEvent,
RawDanmakuFileCompletedEvent,
VideoPostprocessingCompletedEvent,
SpaceNoEnoughEvent, SpaceNoEnoughEvent,
FileCompletedEvent,
Error, Error,
] ]
@ -30,7 +57,16 @@ EventData = Union[
LiveBeganEventData, LiveBeganEventData,
LiveEndedEventData, LiveEndedEventData,
RoomChangeEventData, RoomChangeEventData,
RecordingStartedEventData,
RecordingFinishedEventData,
RecordingCancelledEventData,
VideoFileCreatedEventData,
VideoFileCompletedEventData,
DanmakuFileCreatedEventData,
DanmakuFileCompletedEventData,
RawDanmakuFileCreatedEventData,
RawDanmakuFileCompletedEventData,
VideoPostprocessingCompletedEventData,
SpaceNoEnoughEventData, SpaceNoEnoughEventData,
FileCompletedEventData,
ErrorData, ErrorData,
] ]

View File

@ -26,5 +26,4 @@ class ExceptionHandler(SwitchableMixin):
def _log_exception(self, exc: BaseException) -> None: def _log_exception(self, exc: BaseException) -> None:
exc_info = (type(exc), exc, exc.__traceback__) exc_info = (type(exc), exc, exc.__traceback__)
msg = 'Unhandled Exception' logger.critical(type(exc).__name__, exc_info=exc_info)
logger.critical(msg, exc_info=exc_info)

View File

@ -3,6 +3,4 @@ import traceback
def format_exception(exc: BaseException) -> str: def format_exception(exc: BaseException) -> str:
exc_info = (type(exc), exc, exc.__traceback__) exc_info = (type(exc), exc, exc.__traceback__)
errmsg = 'Unhandled exception in event loop\n' return ''.join(traceback.format_exception(*exc_info))
errmsg += ''.join(traceback.format_exception(*exc_info))
return errmsg

View File

@ -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):
... ...

View File

@ -1,15 +1,19 @@
from io import BytesIO, SEEK_CUR
from typing import cast from typing import cast
import attr import attr
from .struct_io import StructReader, StructWriter from .struct_io import StructReader, StructWriter
from .io_protocols import RandomIO from .io_protocols import RandomIO
from .exceptions import InvalidFlvHeaderError, InvalidFlvTagError from .exceptions import FlvHeaderError, FlvDataError, FlvTagError
from .models import ( from .models import (
FlvTag, FlvTag,
TagType, TagType,
FlvHeader, FlvHeader,
FlvTagHeader, FlvTagHeader,
TAG_HEADER_SIZE,
AUDIO_TAG_HEADER_SIZE,
VIDEO_TAG_HEADER_SIZE,
AudioTag, AudioTag,
AudioTagHeader, AudioTagHeader,
@ -40,7 +44,7 @@ class FlvParser:
def parse_header(self) -> FlvHeader: def parse_header(self) -> FlvHeader:
signature = self._reader.read(3).decode() signature = self._reader.read(3).decode()
if signature != 'FLV': if signature != 'FLV':
raise InvalidFlvHeaderError(signature) raise FlvHeaderError(signature)
version = self._reader.read_ui8() version = self._reader.read_ui8()
type_flag = self._reader.read_ui8() type_flag = self._reader.read_ui8()
data_offset = self._reader.read_ui32() data_offset = self._reader.read_ui32()
@ -51,75 +55,98 @@ class FlvParser:
def parse_tag(self, *, no_body: bool = False) -> FlvTag: def parse_tag(self, *, no_body: bool = False) -> FlvTag:
offset = self._stream.tell() offset = self._stream.tell()
tag_header = self.parse_flv_tag_header() tag_header_data = self._reader.read(TAG_HEADER_SIZE)
tag_header = self.parse_flv_tag_header(tag_header_data)
tag: FlvTag if tag_header.data_size <= 0:
arguments = dict(attr.asdict(tag_header), offset=offset) raise FlvTagError('No tag data', tag_header)
if tag_header.tag_type == TagType.AUDIO: if tag_header.tag_type == TagType.AUDIO:
audio_tag_header = self.parse_audio_tag_header() header_data = self._reader.read(AUDIO_TAG_HEADER_SIZE)
arguments.update(attr.asdict(audio_tag_header)) body_size = tag_header.data_size - AUDIO_TAG_HEADER_SIZE
tag = AudioTag(**arguments)
elif tag_header.tag_type == TagType.VIDEO:
video_tag_header = self.parse_video_tag_header()
arguments.update(attr.asdict(video_tag_header))
tag = VideoTag(**arguments)
elif tag_header.tag_type == TagType.SCRIPT:
tag = ScriptTag(**arguments)
else:
raise InvalidFlvTagError(tag_header.tag_type)
if no_body: if no_body:
self._stream.seek(tag.tag_end_offset) self._stream.seek(body_size, SEEK_CUR)
body = None
else: else:
body = self._reader.read(tag.body_size) body = self._reader.read(body_size)
tag = tag.evolve(body=body) audio_tag_header = self.parse_audio_tag_header(header_data)
return AudioTag(
offset=offset,
**attr.asdict(tag_header),
**attr.asdict(audio_tag_header),
body=body,
)
elif tag_header.tag_type == TagType.VIDEO:
header_data = self._reader.read(VIDEO_TAG_HEADER_SIZE)
body_size = tag_header.data_size - VIDEO_TAG_HEADER_SIZE
if no_body:
self._stream.seek(body_size, SEEK_CUR)
body = None
else:
body = self._reader.read(body_size)
video_tag_header = self.parse_video_tag_header(header_data)
return VideoTag(
offset=offset,
**attr.asdict(tag_header),
**attr.asdict(video_tag_header),
body=body,
)
elif tag_header.tag_type == TagType.SCRIPT:
body_size = tag_header.data_size
if no_body:
self._stream.seek(body_size, SEEK_CUR)
body = None
else:
body = self._reader.read(body_size)
return ScriptTag(
offset=offset,
**attr.asdict(tag_header),
body=body,
)
else:
raise FlvDataError(f'Unsupported tag type: {tag_header.tag_type}')
return tag def parse_flv_tag_header(self, data: bytes) -> FlvTagHeader:
reader = StructReader(BytesIO(data))
def parse_flv_tag_header(self) -> FlvTagHeader: flag = reader.read_ui8()
flag = self._reader.read_ui8()
filtered = bool(flag & 0b0010_0000) filtered = bool(flag & 0b0010_0000)
if filtered: if filtered:
raise NotImplementedError('Unsupported Filtered FLV Tag') raise FlvDataError('Unsupported Filtered FLV Tag', data)
tag_type = TagType(flag & 0b0001_1111) tag_type = TagType(flag & 0b0001_1111)
data_size = self._reader.read_ui24() data_size = reader.read_ui24()
timestamp = self._reader.read_ui24() timestamp = reader.read_ui24()
timestamp_extended = self._reader.read_ui8() timestamp_extended = reader.read_ui8()
timestamp = timestamp_extended << 24 | timestamp timestamp = timestamp_extended << 24 | timestamp
stream_id = self._reader.read_ui24() stream_id = reader.read_ui24()
tag_header = FlvTagHeader( return FlvTagHeader(
filtered, tag_type, data_size, timestamp, stream_id filtered, tag_type, data_size, timestamp, stream_id
) )
if data_size <= 0:
raise InvalidFlvTagError(tag_header)
return tag_header
def parse_audio_tag_header(self) -> AudioTagHeader: def parse_audio_tag_header(self, data: bytes) -> AudioTagHeader:
flag = self._reader.read_ui8() reader = StructReader(BytesIO(data))
flag = reader.read_ui8()
sound_format = SoundFormat(flag >> 4) sound_format = SoundFormat(flag >> 4)
if sound_format != SoundFormat.AAC: if sound_format != SoundFormat.AAC:
raise NotImplementedError( raise FlvDataError(
f'Unsupported sound format: {sound_format}' f'Unsupported sound format: {sound_format}', data
) )
sound_rate = SoundRate((flag >> 2) & 0b0000_0011) sound_rate = SoundRate((flag >> 2) & 0b0000_0011)
sound_size = SoundSize((flag >> 1) & 0b0000_0001) sound_size = SoundSize((flag >> 1) & 0b0000_0001)
sound_type = SoundType(flag & 0b0000_0001) sound_type = SoundType(flag & 0b0000_0001)
aac_packet_type = AACPacketType(self._reader.read_ui8()) aac_packet_type = AACPacketType(reader.read_ui8())
return AudioTagHeader( return AudioTagHeader(
sound_format, sound_rate, sound_size, sound_type, aac_packet_type sound_format, sound_rate, sound_size, sound_type, aac_packet_type
) )
def parse_video_tag_header(self) -> VideoTagHeader: def parse_video_tag_header(self, data: bytes) -> VideoTagHeader:
flag = self._reader.read_ui8() reader = StructReader(BytesIO(data))
flag = reader.read_ui8()
frame_type = FrameType(flag >> 4) frame_type = FrameType(flag >> 4)
codec_id = CodecID(flag & 0b0000_1111) codec_id = CodecID(flag & 0b0000_1111)
if codec_id != CodecID.AVC: if codec_id != CodecID.AVC:
raise NotImplementedError( raise FlvDataError(f'Unsupported video codec: {codec_id}', data)
f'Unsupported video codec: {codec_id}' avc_packet_type = AVCPacketType(reader.read_ui8())
) composition_time = reader.read_ui24()
avc_packet_type = AVCPacketType(self._reader.read_ui8())
composition_time = self._reader.read_ui24()
return VideoTagHeader( return VideoTagHeader(
frame_type, codec_id, avc_packet_type, composition_time frame_type, codec_id, avc_packet_type, composition_time
) )
@ -151,7 +178,7 @@ class FlvDumper:
elif tag.is_script_tag(): elif tag.is_script_tag():
pass pass
else: else:
raise InvalidFlvTagError(tag.tag_type) raise FlvDataError(f'Unsupported tag type: {tag.tag_type}')
if tag.body is None: if tag.body is None:
self._stream.seek(tag.tag_end_offset) self._stream.seek(tag.tag_end_offset)
@ -167,8 +194,8 @@ class FlvDumper:
def dump_audio_tag_header(self, tag: AudioTag) -> None: def dump_audio_tag_header(self, tag: AudioTag) -> None:
if tag.sound_format != SoundFormat.AAC: if tag.sound_format != SoundFormat.AAC:
raise NotImplementedError( raise FlvDataError(
f'Unsupported sound format: {tag.sound_format}' f'Unsupported sound format: {tag.sound_format}', tag
) )
self._writer.write_ui8( self._writer.write_ui8(
(tag.sound_format.value << 4) | (tag.sound_format.value << 4) |
@ -180,9 +207,7 @@ class FlvDumper:
def dump_video_tag_header(self, tag: VideoTag) -> None: def dump_video_tag_header(self, tag: VideoTag) -> None:
if tag.codec_id != CodecID.AVC: if tag.codec_id != CodecID.AVC:
raise NotImplementedError( raise FlvDataError(f'Unsupported video codec: {tag.codec_id}', tag)
f'Unsupported video codec: {tag.codec_id}'
)
self._writer.write_ui8( self._writer.write_ui8(
(tag.frame_type.value << 4) | tag.codec_id.value (tag.frame_type.value << 4) | tag.codec_id.value
) )

View File

@ -137,8 +137,10 @@ class VideoTagHeader:
composition_time: Optional[int] composition_time: Optional[int]
TAG_HEADER_SIZE: Final[int] = 11
BACK_POINTER_SIZE: Final[int] = 4 BACK_POINTER_SIZE: Final[int] = 4
TAG_HEADER_SIZE: Final[int] = 11
AUDIO_TAG_HEADER_SIZE: Final[int] = 2
VIDEO_TAG_HEADER_SIZE: Final[int] = 5
_T = TypeVar('_T', bound='FlvTag') _T = TypeVar('_T', bound='FlvTag')

View File

@ -22,7 +22,7 @@ from .io import FlvReader, FlvWriter
from .io_protocols import RandomIO from .io_protocols import RandomIO
from .utils import format_offest, format_timestamp from .utils import format_offest, format_timestamp
from .exceptions import ( from .exceptions import (
FlvDataError, FlvTagError,
FlvStreamCorruptedError, FlvStreamCorruptedError,
AudioParametersChanged, AudioParametersChanged,
VideoParametersChanged, VideoParametersChanged,
@ -32,8 +32,8 @@ from .exceptions import (
) )
from .common import ( from .common import (
is_audio_tag, is_metadata_tag, is_video_tag, parse_metadata, is_audio_tag, is_metadata_tag, is_video_tag, parse_metadata,
is_audio_data_tag, is_video_data_tag, is_sequence_header,
enrich_metadata, update_metadata, is_data_tag, read_tags_in_duration, enrich_metadata, update_metadata, is_data_tag, read_tags_in_duration,
is_sequence_header
) )
from ..path import extra_metadata_path from ..path import extra_metadata_path
@ -71,6 +71,7 @@ class StreamProcessor:
self._data_analyser = DataAnalyser() self._data_analyser = DataAnalyser()
self._metadata = metadata.copy() if metadata else {} self._metadata = metadata.copy() if metadata else {}
self._metadata_tag: ScriptTag
self._disable_limit = disable_limit self._disable_limit = disable_limit
self._analyse_data = analyse_data self._analyse_data = analyse_data
@ -85,9 +86,9 @@ class StreamProcessor:
self._delta: int = 0 self._delta: int = 0
self._has_audio: bool = False self._has_audio: bool = False
self._metadata_tag: ScriptTag
self._last_tags: List[FlvTag] = [] self._last_tags: List[FlvTag] = []
self._join_points: List[JoinPoint] = [] self._join_points: List[JoinPoint] = []
self._resetting_file: bool = False
@property @property
def filesize_limit(self) -> int: def filesize_limit(self) -> int:
@ -162,16 +163,29 @@ class StreamProcessor:
def _need_to_finalize(self) -> bool: def _need_to_finalize(self) -> bool:
return self._stream_count > 0 and len(self._last_tags) > 0 return self._stream_count > 0 and len(self._last_tags) > 0
def _new_file(self) -> None: def _reset_params(self) -> None:
self._delta = 0
self._has_audio = False
self._last_tags = []
self._join_points = []
self._resetting_file = False
self._stream_cutter.reset() self._stream_cutter.reset()
if not self._disable_limit: if not self._disable_limit:
self._limit_checker.reset() self._limit_checker.reset()
if self._analyse_data:
self._data_analyser.reset()
def _new_file(self) -> None:
self._reset_params()
self._out_file = self._file_manager.create_file() self._out_file = self._file_manager.create_file()
self._out_reader = FlvReader(self._out_file)
self._out_writer = FlvWriter(self._out_file) self._out_writer = FlvWriter(self._out_file)
logger.debug(f'New file: {self._file_manager.curr_path}')
logger.debug(f'new file: {self._file_manager.curr_path}') def _reset_file(self) -> None:
self._reset_params()
self._out_file.truncate(0)
logger.debug(f'Reset file: {self._file_manager.curr_path}')
def _complete_file(self) -> None: def _complete_file(self) -> None:
curr_path = self._file_manager.curr_path curr_path = self._file_manager.curr_path
@ -182,26 +196,7 @@ class StreamProcessor:
self._update_metadata_tag() self._update_metadata_tag()
self._file_manager.close_file() self._file_manager.close_file()
if self._analyse_data: logger.debug(f'Complete file: {curr_path}')
self._data_analyser.reset()
logger.debug(f'complete file: {curr_path}')
def _discard_file(self) -> None:
curr_path = self._file_manager.curr_path
self._file_manager.close_file()
if self._analyse_data:
self._data_analyser.reset()
logger.debug(f'discard file: {curr_path}')
def _reset(self) -> None:
self._discard_file()
self._last_tags = []
self._stream_count = 0
logger.debug('Reset stream processing')
def _process_stream(self, stream: RandomIO) -> None: def _process_stream(self, stream: RandomIO) -> None:
logger.debug(f'Processing the {self._stream_count}th stream...') logger.debug(f'Processing the {self._stream_count}th stream...')
@ -227,6 +222,9 @@ class StreamProcessor:
def _process_initial_stream( def _process_initial_stream(
self, flv_header: FlvHeader, first_data_tag: FlvTag self, flv_header: FlvHeader, first_data_tag: FlvTag
) -> None: ) -> None:
if self._resetting_file:
self._reset_file()
else:
self._new_file() self._new_file()
try: try:
@ -234,7 +232,8 @@ class StreamProcessor:
self._transfer_meta_tags() self._transfer_meta_tags()
self._transfer_first_data_tag(first_data_tag) self._transfer_first_data_tag(first_data_tag)
except Exception: except Exception:
self._reset() self._last_tags = []
self._resetting_file = True
raise raise
else: else:
del flv_header, first_data_tag del flv_header, first_data_tag
@ -376,8 +375,6 @@ class StreamProcessor:
except EOFError: except EOFError:
logger.debug('The input stream exhausted') logger.debug('The input stream exhausted')
break break
except FlvDataError as e:
raise FlvStreamCorruptedError(repr(e))
except Exception as e: except Exception as e:
logger.debug(f'Failed to read data, due to: {repr(e)}') logger.debug(f'Failed to read data, due to: {repr(e)}')
raise raise
@ -493,6 +490,8 @@ class StreamProcessor:
return header return header
def _ensure_ts_correct(self, tag: FlvTag) -> None: def _ensure_ts_correct(self, tag: FlvTag) -> None:
if not is_audio_data_tag(tag) or not is_video_data_tag(tag):
return
if tag.timestamp + self._delta < 0: if tag.timestamp + self._delta < 0:
self._delta = -tag.timestamp self._delta = -tag.timestamp
logger.warning('Incorrect timestamp: {}, new delta: {}'.format( logger.warning('Incorrect timestamp: {}, new delta: {}'.format(
@ -500,9 +499,9 @@ class StreamProcessor:
)) ))
def _correct_ts(self, tag: FlvTag, delta: int) -> FlvTag: def _correct_ts(self, tag: FlvTag, delta: int) -> FlvTag:
if delta == 0: if delta == 0 and tag.timestamp >= 0:
return tag return tag
return tag.evolve(timestamp=tag.timestamp + delta) return tag.evolve(timestamp=max(0, tag.timestamp + delta))
def _calc_delta_duplicated(self, last_duplicated_tag: FlvTag) -> int: def _calc_delta_duplicated(self, last_duplicated_tag: FlvTag) -> int:
return self._last_tags[0].timestamp - last_duplicated_tag.timestamp return self._last_tags[0].timestamp - last_duplicated_tag.timestamp
@ -685,7 +684,24 @@ class BaseOutputFileManager(ABC):
... ...
class FlvReaderWithTimestampFix(FlvReader): class RobustFlvReader(FlvReader):
def read_tag(self, *, no_body: bool = False) -> FlvTag:
count = 0
while True:
try:
tag = super().read_tag(no_body=no_body)
except FlvTagError as e:
logger.warning(f'Invalid tag: {repr(e)}')
self._parser.parse_previous_tag_size()
count += 1
if count > 3:
raise
else:
count = 0
return tag
class FlvReaderWithTimestampFix(RobustFlvReader):
def __init__(self, stream: RandomIO) -> None: def __init__(self, stream: RandomIO) -> None:
super().__init__(stream) super().__init__(stream)
self._last_tag: Optional[FlvTag] = None self._last_tag: Optional[FlvTag] = None

View File

@ -1,3 +1,4 @@
from __future__ import annotations
import asyncio import asyncio
import logging import logging
from pathlib import PurePath from pathlib import PurePath
@ -35,7 +36,9 @@ logger = logging.getLogger(__name__)
class PostprocessorEventListener(EventListener): class PostprocessorEventListener(EventListener):
async def on_file_completed(self, room_id: int, path: str) -> None: async def on_video_postprocessing_completed(
self, postprocessor: Postprocessor, path: str
) -> None:
... ...
@ -68,6 +71,10 @@ class Postprocessor(
self._postprocessing_progress: Optional[Progress] = None self._postprocessing_progress: Optional[Progress] = None
self._completed_files: List[str] = [] self._completed_files: List[str] = []
@property
def recorder(self) -> Recorder:
return self._recorder
@property @property
def status(self) -> PostprocessorStatus: def status(self) -> PostprocessorStatus:
return self._status return self._status
@ -87,10 +94,14 @@ class Postprocessor(
# clear completed files of previous recording # clear completed files of previous recording
self._completed_files.clear() self._completed_files.clear()
async def on_video_file_completed(self, path: str) -> None: async def on_video_file_completed(
self, recorder: Recorder, path: str
) -> None:
self._queue.put_nowait(path) self._queue.put_nowait(path)
async def on_danmaku_file_completed(self, path: str) -> None: async def on_danmaku_file_completed(
self, recorder: Recorder, path: str
) -> None:
self._completed_files.append(path) self._completed_files.append(path)
async def _do_start(self) -> None: async def _do_start(self) -> None:
@ -143,7 +154,7 @@ class Postprocessor(
self._completed_files.append(result_path) self._completed_files.append(result_path)
await self._emit( await self._emit(
'file_completed', self._live.room_id, result_path 'video_postprocessing_completed', self, result_path,
) )
except Exception as exc: except Exception as exc:
submit_exception(exc) submit_exception(exc)

View File

@ -18,13 +18,10 @@ from pydantic import BaseModel as PydanticBaseModel
from pydantic import Field, BaseSettings, validator, PrivateAttr, DirectoryPath from pydantic import Field, BaseSettings, validator, PrivateAttr, DirectoryPath
from pydantic.networks import HttpUrl, EmailStr from pydantic.networks import HttpUrl, EmailStr
import typer
from ..bili.typing import QualityNumber from ..bili.typing import QualityNumber
from ..postprocess import DeleteStrategy from ..postprocess import DeleteStrategy
from ..logging.typing import LOG_LEVEL from ..logging.typing import LOG_LEVEL
from ..utils.string import camel_case from ..utils.string import camel_case
from ..path.helpers import file_exists, create_file
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -67,22 +64,8 @@ __all__ = (
DEFAULT_SETTINGS_PATH: Final[str] = '~/.blrec/settings.toml' DEFAULT_SETTINGS_PATH: Final[str] = '~/.blrec/settings.toml'
def settings_file_factory() -> str:
path = os.path.abspath(os.path.expanduser(DEFAULT_SETTINGS_PATH))
if not file_exists(path):
create_file(path)
typer.secho(
f"Created setting file: '{path}'",
fg=typer.colors.BRIGHT_MAGENTA,
bold=True,
)
return path
class EnvSettings(BaseSettings): class EnvSettings(BaseSettings):
settings_file: Annotated[ settings_file: Annotated[str, Field(env='config')] = DEFAULT_SETTINGS_PATH
str, Field(env='config', default_factory=settings_file_factory)
]
out_dir: Optional[str] = None out_dir: Optional[str] = None
api_key: Annotated[ api_key: Annotated[
Optional[str], Optional[str],
@ -98,7 +81,6 @@ _V = TypeVar('_V')
class BaseModel(PydanticBaseModel): class BaseModel(PydanticBaseModel):
class Config: class Config:
extra = 'forbid'
validate_assignment = True validate_assignment = True
anystr_strip_whitespace = True anystr_strip_whitespace = True
allow_population_by_field_name = True allow_population_by_field_name = True
@ -133,6 +115,7 @@ class HeaderSettings(HeaderOptions):
class DanmakuOptions(BaseModel): class DanmakuOptions(BaseModel):
danmu_uname: Optional[bool] danmu_uname: Optional[bool]
record_gift_send: Optional[bool] record_gift_send: Optional[bool]
record_free_gifts: Optional[bool]
record_guard_buy: Optional[bool] record_guard_buy: Optional[bool]
record_super_chat: Optional[bool] record_super_chat: Optional[bool]
save_raw_danmaku: Optional[bool] save_raw_danmaku: Optional[bool]
@ -141,6 +124,7 @@ class DanmakuOptions(BaseModel):
class DanmakuSettings(DanmakuOptions): class DanmakuSettings(DanmakuOptions):
danmu_uname: bool = False danmu_uname: bool = False
record_gift_send: bool = True record_gift_send: bool = True
record_free_gifts: bool = True
record_guard_buy: bool = True record_guard_buy: bool = True
record_super_chat: bool = True record_super_chat: bool = True
save_raw_danmaku: bool = False save_raw_danmaku: bool = False
@ -302,13 +286,13 @@ class SpaceSettings(BaseModel):
@validator('check_interval') @validator('check_interval')
def _validate_interval(cls, value: int) -> int: def _validate_interval(cls, value: int) -> int:
allowed_values = frozenset(60 * i for i in (1, 3, 5, 10)) allowed_values = frozenset((10, 30, *(60 * i for i in (1, 3, 5, 10))))
cls._validate_with_collection(value, allowed_values) cls._validate_with_collection(value, allowed_values)
return value return value
@validator('space_threshold') @validator('space_threshold')
def _validate_threshold(cls, value: int) -> int: def _validate_threshold(cls, value: int) -> int:
allowed_values = frozenset(1024 ** 3 * i for i in (1, 3, 5, 10)) allowed_values = frozenset(1024 ** 3 * i for i in (1, 3, 5, 10, 20))
cls._validate_with_collection(value, allowed_values) cls._validate_with_collection(value, allowed_values)
return value return value
@ -375,8 +359,17 @@ class WebHookEventSettings(BaseModel):
live_began: bool = True live_began: bool = True
live_ended: bool = True live_ended: bool = True
room_change: bool = True room_change: bool = True
recording_started: bool = True
recording_finished: bool = True
recording_cancelled: bool = True
video_file_created: bool = True
video_file_completed: bool = True
danmaku_file_created: bool = True
danmaku_file_completed: bool = True
raw_danmaku_file_created: bool = True
raw_danmaku_file_completed: bool = True
video_postprocessing_completed: bool = True
space_no_enough: bool = True space_no_enough: bool = True
file_completed: bool = True
error_occurred: bool = True error_occurred: bool = True

View File

@ -47,6 +47,7 @@ class TaskParam:
# DanmakuSettings # DanmakuSettings
danmu_uname: bool danmu_uname: bool
record_gift_send: bool record_gift_send: bool
record_free_gifts: bool
record_guard_buy: bool record_guard_buy: bool
record_super_chat: bool record_super_chat: bool
save_raw_danmaku: bool save_raw_danmaku: bool

View File

@ -22,7 +22,7 @@ from ..postprocess import Postprocessor, PostprocessorStatus, DeleteStrategy
from ..postprocess.remuxer import RemuxProgress from ..postprocess.remuxer import RemuxProgress
from ..flv.metadata_injector import InjectProgress from ..flv.metadata_injector import InjectProgress
from ..event.event_submitters import ( from ..event.event_submitters import (
LiveEventSubmitter, PostprocessorEventSubmitter LiveEventSubmitter, RecorderEventSubmitter, PostprocessorEventSubmitter
) )
from ..logging.room_id import aio_task_with_room_id from ..logging.room_id import aio_task_with_room_id
@ -44,6 +44,7 @@ class RecordTask:
user_agent: str = '', user_agent: str = '',
danmu_uname: bool = False, danmu_uname: bool = False,
record_gift_send: bool = False, record_gift_send: bool = False,
record_free_gifts: bool = False,
record_guard_buy: bool = False, record_guard_buy: bool = False,
record_super_chat: bool = False, record_super_chat: bool = False,
save_cover: bool = False, save_cover: bool = False,
@ -68,6 +69,7 @@ class RecordTask:
self._user_agent = user_agent self._user_agent = user_agent
self._danmu_uname = danmu_uname self._danmu_uname = danmu_uname
self._record_gift_send = record_gift_send self._record_gift_send = record_gift_send
self._record_free_gifts = record_free_gifts
self._record_guard_buy = record_guard_buy self._record_guard_buy = record_guard_buy
self._record_super_chat = record_super_chat self._record_super_chat = record_super_chat
self._save_cover = save_cover self._save_cover = save_cover
@ -239,6 +241,14 @@ class RecordTask:
def record_gift_send(self, value: bool) -> None: def record_gift_send(self, value: bool) -> None:
self._recorder.record_gift_send = value self._recorder.record_gift_send = value
@property
def record_free_gifts(self) -> bool:
return self._recorder.record_free_gifts
@record_free_gifts.setter
def record_free_gifts(self, value: bool) -> None:
self._recorder.record_free_gifts = value
@property @property
def record_guard_buy(self) -> bool: def record_guard_buy(self) -> bool:
return self._recorder.record_guard_buy return self._recorder.record_guard_buy
@ -442,6 +452,7 @@ class RecordTask:
self._setup_live_monitor() self._setup_live_monitor()
self._setup_live_event_submitter() self._setup_live_event_submitter()
self._setup_recorder() self._setup_recorder()
self._setup_recorder_event_submitter()
self._setup_postprocessor() self._setup_postprocessor()
self._setup_postprocessor_event_submitter() self._setup_postprocessor_event_submitter()
@ -468,6 +479,7 @@ class RecordTask:
disconnection_timeout=self._disconnection_timeout, disconnection_timeout=self._disconnection_timeout,
danmu_uname=self._danmu_uname, danmu_uname=self._danmu_uname,
record_gift_send=self._record_gift_send, record_gift_send=self._record_gift_send,
record_free_gifts=self._record_free_gifts,
record_guard_buy=self._record_guard_buy, record_guard_buy=self._record_guard_buy,
record_super_chat=self._record_super_chat, record_super_chat=self._record_super_chat,
save_cover=self._save_cover, save_cover=self._save_cover,
@ -476,9 +488,8 @@ class RecordTask:
duration_limit=self._duration_limit, duration_limit=self._duration_limit,
) )
def _setup_postprocessor_event_submitter(self) -> None: def _setup_recorder_event_submitter(self) -> None:
self._postprocessor_event_submitter = \ self._recorder_event_submitter = RecorderEventSubmitter(self._recorder)
PostprocessorEventSubmitter(self._postprocessor)
def _setup_postprocessor(self) -> None: def _setup_postprocessor(self) -> None:
self._postprocessor = Postprocessor( self._postprocessor = Postprocessor(
@ -489,8 +500,14 @@ class RecordTask:
delete_source=self._delete_source, delete_source=self._delete_source,
) )
def _setup_postprocessor_event_submitter(self) -> None:
self._postprocessor_event_submitter = \
PostprocessorEventSubmitter(self._postprocessor)
async def _destroy(self) -> None: async def _destroy(self) -> None:
self._destroy_postprocessor_event_submitter() self._destroy_postprocessor_event_submitter()
self._destroy_postprocessor()
self._destroy_recorder_event_submitter()
self._destroy_recorder() self._destroy_recorder()
self._destroy_live_event_submitter() self._destroy_live_event_submitter()
self._destroy_live_monitor() self._destroy_live_monitor()
@ -508,8 +525,11 @@ class RecordTask:
def _destroy_recorder(self) -> None: def _destroy_recorder(self) -> None:
del self._recorder del self._recorder
def _destroy_postprocessor_event_submitter(self) -> None: def _destroy_recorder_event_submitter(self) -> None:
del self._postprocessor_event_submitter del self._recorder_event_submitter
def _destroy_postprocessor(self) -> None: def _destroy_postprocessor(self) -> None:
del self._postprocessor del self._postprocessor
def _destroy_postprocessor_event_submitter(self) -> None:
del self._postprocessor_event_submitter

View File

@ -221,6 +221,7 @@ class RecordTaskManager:
task = self._get_task(room_id) task = self._get_task(room_id)
task.danmu_uname = settings.danmu_uname task.danmu_uname = settings.danmu_uname
task.record_gift_send = settings.record_gift_send task.record_gift_send = settings.record_gift_send
task.record_free_gifts = settings.record_free_gifts
task.record_guard_buy = settings.record_guard_buy task.record_guard_buy = settings.record_guard_buy
task.record_super_chat = settings.record_super_chat task.record_super_chat = settings.record_super_chat
task.save_raw_danmaku = settings.save_raw_danmaku task.save_raw_danmaku = settings.save_raw_danmaku
@ -259,6 +260,7 @@ class RecordTaskManager:
cookie=task.cookie, cookie=task.cookie,
danmu_uname=task.danmu_uname, danmu_uname=task.danmu_uname,
record_gift_send=task.record_gift_send, record_gift_send=task.record_gift_send,
record_free_gifts=task.record_free_gifts,
record_guard_buy=task.record_guard_buy, record_guard_buy=task.record_guard_buy,
record_super_chat=task.record_super_chat, record_super_chat=task.record_super_chat,
save_cover=task.save_cover, save_cover=task.save_cover,

View File

@ -1,16 +1,15 @@
import os import os
import logging import logging
import secrets
from typing import Optional, Tuple from typing import Optional, Tuple
from fastapi import FastAPI, status, Request, Depends, Header from fastapi import FastAPI, status, Request, Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.exceptions import HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import ValidationError from pydantic import ValidationError
from pkg_resources import resource_filename from pkg_resources import resource_filename
from . import security
from .routers import ( from .routers import (
tasks, settings, application, validation, websockets, update tasks, settings, application, validation, websockets, update
) )
@ -18,33 +17,28 @@ from .schemas import ResponseMessage
from ..setting import EnvSettings, Settings from ..setting import EnvSettings, Settings
from ..application import Application from ..application import Application
from ..exception import NotFoundError, ExistsError, ForbiddenError from ..exception import NotFoundError, ExistsError, ForbiddenError
from ..path.helpers import file_exists, create_file
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_env_settings = EnvSettings() _env_settings = EnvSettings()
_path = os.path.abspath(os.path.expanduser(_env_settings.settings_file))
if not file_exists(_path):
create_file(_path)
_env_settings.settings_file = _path
_settings = Settings.load(_env_settings.settings_file) _settings = Settings.load(_env_settings.settings_file)
_settings.update_from_env_settings(_env_settings) _settings.update_from_env_settings(_env_settings)
app = Application(_settings) app = Application(_settings)
if _env_settings.api_key is None: if _env_settings.api_key is None:
_dependencies = None _dependencies = None
else: else:
async def validate_api_key( security.api_key = _env_settings.api_key
x_api_key: Optional[str] = Header(None) _dependencies = [Depends(security.authenticate)]
) -> None:
assert _env_settings.api_key is not None
if (
x_api_key is None or
not secrets.compare_digest(x_api_key, _env_settings.api_key)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='API key is missing or invalid',
)
_dependencies = [Depends(validate_api_key)]
api = FastAPI( api = FastAPI(
title='Bilibili live streaming recorder web API', title='Bilibili live streaming recorder web API',

View File

@ -1,12 +1,13 @@
import logging import logging
import asyncio import asyncio
import json
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
WebSocket, WebSocket,
WebSocketDisconnect, WebSocketDisconnect,
) )
from websockets import ConnectionClosed # type: ignore from websockets.exceptions import ConnectionClosed
from ...event import EventCenter from ...event import EventCenter
from ...event.typing import Event from ...event.typing import Event
@ -33,7 +34,8 @@ async def receive_events(websocket: WebSocket) -> None:
async def send_event(event: Event) -> None: async def send_event(event: Event) -> None:
try: try:
await websocket.send_json(event.asdict()) text = json.dumps(event.asdict(), ensure_ascii=False)
await websocket.send_text(text)
except (WebSocketDisconnect, ConnectionClosed) as e: except (WebSocketDisconnect, ConnectionClosed) as e:
logger.debug(f'Events websocket closed: {repr(e)}') logger.debug(f'Events websocket closed: {repr(e)}')
subscription.dispose() subscription.dispose()

81
src/blrec/web/security.py Normal file
View 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)

View File

@ -9,8 +9,17 @@ from ..event import (
LiveBeganEvent, LiveBeganEvent,
LiveEndedEvent, LiveEndedEvent,
RoomChangeEvent, RoomChangeEvent,
RecordingStartedEvent,
RecordingFinishedEvent,
RecordingCancelledEvent,
VideoFileCreatedEvent,
VideoFileCompletedEvent,
DanmakuFileCreatedEvent,
DanmakuFileCompletedEvent,
RawDanmakuFileCreatedEvent,
RawDanmakuFileCompletedEvent,
SpaceNoEnoughEvent, SpaceNoEnoughEvent,
FileCompletedEvent, VideoPostprocessingCompletedEvent,
) )
from ..event.typing import Event from ..event.typing import Event
@ -37,8 +46,26 @@ class WebHook:
types.add(LiveEndedEvent) types.add(LiveEndedEvent)
if settings.room_change: if settings.room_change:
types.add(RoomChangeEvent) types.add(RoomChangeEvent)
if settings.file_completed: if settings.recording_started:
types.add(FileCompletedEvent) types.add(RecordingStartedEvent)
if settings.recording_finished:
types.add(RecordingFinishedEvent)
if settings.recording_cancelled:
types.add(RecordingCancelledEvent)
if settings.video_file_created:
types.add(VideoFileCreatedEvent)
if settings.video_file_completed:
types.add(VideoFileCompletedEvent)
if settings.danmaku_file_created:
types.add(DanmakuFileCreatedEvent)
if settings.danmaku_file_completed:
types.add(DanmakuFileCompletedEvent)
if settings.raw_danmaku_file_created:
types.add(RawDanmakuFileCreatedEvent)
if settings.raw_danmaku_file_completed:
types.add(RawDanmakuFileCompletedEvent)
if settings.video_postprocessing_completed:
types.add(VideoPostprocessingCompletedEvent)
if settings.space_no_enough: if settings.space_no_enough:
types.add(SpaceNoEnoughEvent) types.add(SpaceNoEnoughEvent)

View File

@ -11,7 +11,7 @@ from tenacity import (
from .models import WebHook from .models import WebHook
from ..utils.mixins import SwitchableMixin from ..utils.mixins import SwitchableMixin
from ..exception import ExceptionCenter, format_exception from ..exception import ExceptionCenter
from ..event import EventCenter, Error, ErrorData from ..event import EventCenter, Error, ErrorData
from ..event.typing import Event from ..event.typing import Event
from .. import __prog__, __version__ from .. import __prog__, __version__
@ -57,11 +57,7 @@ class WebHookEmitter(SwitchableMixin):
self._send_request(url, event.asdict()) self._send_request(url, event.asdict())
def _send_exception(self, url: str, exc: BaseException) -> None: def _send_exception(self, url: str, exc: BaseException) -> None:
data = ErrorData( payload = Error.from_data(ErrorData.from_exc(exc)).asdict()
name=type(exc).__name__,
detail=format_exception(exc),
)
payload = Error.from_data(data).asdict()
self._send_request(url, payload) self._send_request(url, payload)
def _send_request(self, url: str, payload: Dict[str, Any]) -> None: def _send_request(self, url: str, payload: Dict[str, Any]) -> None:

View File

@ -8,7 +8,7 @@ import {
} from '@angular/common/http'; } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
import { catchError } from 'rxjs/operators'; import { catchError, retry } from 'rxjs/operators';
@Injectable() @Injectable()
export class AuthInterceptor implements HttpInterceptor { export class AuthInterceptor implements HttpInterceptor {
@ -26,11 +26,15 @@ export class AuthInterceptor implements HttpInterceptor {
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {
if (error.status === 401) { if (error.status === 401) {
// Unauthorized // Unauthorized
if (this.auth.hasApiKey()) {
this.auth.removeApiKey();
}
const apiKey = window.prompt('API Key:') ?? ''; const apiKey = window.prompt('API Key:') ?? '';
this.auth.setApiKey(apiKey); this.auth.setApiKey(apiKey);
} }
throw error; throw error;
}) }),
retry(3)
); );
} }
} }

View File

@ -4,8 +4,17 @@ export type Event =
| LiveBeganEvent | LiveBeganEvent
| LiveEndedEvent | LiveEndedEvent
| RoomChangeEvent | RoomChangeEvent
| RecordingStartedEvent
| RecordingFinishedEvent
| RecordingCancelledEvent
| VideoFileCreatedEvent
| VideoFileCompletedEvent
| DanmakuFileCreatedEvent
| DanmakuFileCompletedEvent
| RawDanmakuFileCreatedEvent
| RawDanmakuFileCompletedEvent
| SpaceNoEnoughEvent | SpaceNoEnoughEvent
| FilesAvailableEvent; | VideoPostprocessingCompletedEvent;
export interface LiveBeganEvent { export interface LiveBeganEvent {
readonly type: 'LiveBeganEvent'; readonly type: 'LiveBeganEvent';
@ -29,6 +38,75 @@ export interface RoomChangeEvent {
readonly room_info: RoomInfo; readonly room_info: RoomInfo;
}; };
} }
export interface RecordingStartedEvent {
readonly type: 'RecordingStartedEvent';
readonly data: {
readonly room_info: RoomInfo;
};
}
export interface RecordingFinishedEvent {
readonly type: 'RecordingFinishedEvent';
readonly data: {
readonly room_info: RoomInfo;
};
}
export interface RecordingCancelledEvent {
readonly type: 'RecordingCancelledEvent';
readonly data: {
readonly room_info: RoomInfo;
};
}
export interface VideoFileCreatedEvent {
readonly type: 'VideoFileCreatedEvent';
readonly data: {
room_id: number;
path: string;
};
}
export interface VideoFileCompletedEvent {
readonly type: 'VideoFileCompletedEvent';
readonly data: {
room_id: number;
path: string;
};
}
export interface DanmakuFileCreatedEvent {
readonly type: 'DanmakuFileCreatedEvent';
readonly data: {
room_id: number;
path: string;
};
}
export interface DanmakuFileCompletedEvent {
readonly type: 'DanmakuFileCompletedEvent';
readonly data: {
room_id: number;
path: string;
};
}
export interface RawDanmakuFileCreatedEvent {
readonly type: 'RawDanmakuFileCreatedEvent';
readonly data: {
room_id: number;
path: string;
};
}
export interface RawDanmakuFileCompletedEvent {
readonly type: 'RawDanmakuFileCompletedEvent';
readonly data: {
room_id: number;
path: string;
};
}
export interface VideoPostprocessingCompletedEvent {
readonly type: 'VideoPostprocessingCompletedEvent';
readonly data: {
room_id: number;
path: string;
};
}
export interface SpaceNoEnoughEvent { export interface SpaceNoEnoughEvent {
readonly type: 'SpaceNoEnoughEvent'; readonly type: 'SpaceNoEnoughEvent';
@ -39,13 +117,6 @@ export interface SpaceNoEnoughEvent {
}; };
} }
export interface FilesAvailableEvent {
readonly type: 'FilesAvailableEvent';
readonly data: {
files: string[];
};
}
export interface DiskUsage { export interface DiskUsage {
total: number; total: number;
used: number; used: number;

View File

@ -9,6 +9,10 @@ const API_KEY_STORAGE_KEY = 'app-api-key';
export class AuthService { export class AuthService {
constructor(private storage: StorageService) {} constructor(private storage: StorageService) {}
hasApiKey(): boolean {
return this.storage.hasData(API_KEY_STORAGE_KEY);
}
getApiKey(): string { getApiKey(): string {
return this.storage.getData(API_KEY_STORAGE_KEY) ?? ''; return this.storage.getData(API_KEY_STORAGE_KEY) ?? '';
} }
@ -16,4 +20,8 @@ export class AuthService {
setApiKey(value: string): void { setApiKey(value: string): void {
this.storage.setData(API_KEY_STORAGE_KEY, value); this.storage.setData(API_KEY_STORAGE_KEY, value);
} }
removeApiKey() {
this.storage.removeData(API_KEY_STORAGE_KEY);
}
} }

View File

@ -3,8 +3,8 @@
<nz-form-label <nz-form-label
class="setting-label" class="setting-label"
nzNoColon nzNoColon
nzTooltipTitle="记录礼信息到弹幕文件里" nzTooltipTitle="记录礼信息到弹幕文件里"
>记录</nz-form-label >记录礼</nz-form-label
> >
<nz-form-control <nz-form-control
class="setting-control switch" class="setting-control switch"
@ -16,6 +16,23 @@
<nz-switch formControlName="recordGiftSend"></nz-switch> <nz-switch formControlName="recordGiftSend"></nz-switch>
</nz-form-control> </nz-form-control>
</nz-form-item> </nz-form-item>
<nz-form-item class="setting-item" appSwitchActionable>
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="记录免费礼物信息到弹幕文件里"
>记录免费礼物</nz-form-label
>
<nz-form-control
class="setting-control switch"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.recordFreeGifts ? recordFreeGiftsControl : 'warning'
"
>
<nz-switch formControlName="recordFreeGifts"></nz-switch>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item" appSwitchActionable> <nz-form-item class="setting-item" appSwitchActionable>
<nz-form-label <nz-form-label
class="setting-label" class="setting-label"

View File

@ -40,6 +40,7 @@ export class DanmakuSettingsComponent implements OnInit, OnChanges {
this.settingsForm = formBuilder.group({ this.settingsForm = formBuilder.group({
danmuUname: [''], danmuUname: [''],
recordGiftSend: [''], recordGiftSend: [''],
recordFreeGifts: [''],
recordGuardBuy: [''], recordGuardBuy: [''],
recordSuperChat: [''], recordSuperChat: [''],
saveRawDanmaku: [''], saveRawDanmaku: [''],
@ -54,6 +55,10 @@ export class DanmakuSettingsComponent implements OnInit, OnChanges {
return this.settingsForm.get('recordGiftSend') as FormControl; return this.settingsForm.get('recordGiftSend') as FormControl;
} }
get recordFreeGiftsControl() {
return this.settingsForm.get('recordFreeGifts') as FormControl;
}
get recordGuardBuyControl() { get recordGuardBuyControl() {
return this.settingsForm.get('recordGuardBuy') as FormControl; return this.settingsForm.get('recordGuardBuy') as FormControl;
} }

View File

@ -33,6 +33,8 @@ export class DiskSpaceSettingsComponent implements OnInit, OnChanges {
readonly syncFailedWarningTip = SYNC_FAILED_WARNING_TIP; readonly syncFailedWarningTip = SYNC_FAILED_WARNING_TIP;
readonly intervalOptions = [ readonly intervalOptions = [
{ label: '10 秒', value: 10 },
{ label: '30 秒', value: 30 },
{ label: '1 分钟', value: 60 }, { label: '1 分钟', value: 60 },
{ label: '3 分钟', value: 180 }, { label: '3 分钟', value: 180 },
{ label: '5 分钟', value: 300 }, { label: '5 分钟', value: 300 },
@ -44,6 +46,7 @@ export class DiskSpaceSettingsComponent implements OnInit, OnChanges {
{ label: '3 GB', value: 1024 ** 3 * 3 }, { label: '3 GB', value: 1024 ** 3 * 3 },
{ label: '5 GB', value: 1024 ** 3 * 5 }, { label: '5 GB', value: 1024 ** 3 * 5 },
{ label: '10 GB', value: 1024 ** 3 * 10 }, { label: '10 GB', value: 1024 ** 3 * 10 },
{ label: '20 GB', value: 1024 ** 3 * 20 },
]; ];
constructor( constructor(

View File

@ -10,6 +10,7 @@ export type HeaderOptions = Nullable<HeaderSettings>;
export interface DanmakuSettings { export interface DanmakuSettings {
danmuUname: boolean; danmuUname: boolean;
recordGiftSend: boolean; recordGiftSend: boolean;
recordFreeGifts: boolean;
recordGuardBuy: boolean; recordGuardBuy: boolean;
recordSuperChat: boolean; recordSuperChat: boolean;
saveRawDanmaku: boolean; saveRawDanmaku: boolean;
@ -164,8 +165,17 @@ export interface WebhookEventSettings {
liveBegan: boolean; liveBegan: boolean;
liveEnded: boolean; liveEnded: boolean;
roomChange: boolean; roomChange: boolean;
recordingStarted: boolean;
recordingFinished: boolean;
recordingCancelled: boolean;
videoFileCreated: boolean;
videoFileCompleted: boolean;
danmakuFileCreated: boolean;
danmakuFileCompleted: boolean;
rawDanmakuFileCreated: boolean;
rawDanmakuFileCompleted: boolean;
videoPostprocessingCompleted: boolean;
spaceNoEnough: boolean; spaceNoEnough: boolean;
fileCompleted: boolean;
errorOccurred: boolean; errorOccurred: boolean;
} }

View File

@ -62,8 +62,71 @@
</nz-form-item> </nz-form-item>
<nz-form-item class="setting-item"> <nz-form-item class="setting-item">
<nz-form-control class="setting-control checkbox"> <nz-form-control class="setting-control checkbox">
<label nz-checkbox formControlName="fileCompleted" <label nz-checkbox formControlName="recordingStarted"
>录播文件完成</label >录制开始</label
>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-control class="setting-control checkbox">
<label nz-checkbox formControlName="recordingFinished"
>录制完成</label
>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-control class="setting-control checkbox">
<label nz-checkbox formControlName="recordingCancelled"
>录制取消</label
>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-control class="setting-control checkbox">
<label nz-checkbox formControlName="videoFileCreated"
>视频文件创建</label
>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-control class="setting-control checkbox">
<label nz-checkbox formControlName="videoFileCompleted"
>视频文件完成</label
>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-control class="setting-control checkbox">
<label nz-checkbox formControlName="danmakuFileCreated"
>弹幕文件创建</label
>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-control class="setting-control checkbox">
<label nz-checkbox formControlName="danmakuFileCompleted"
>弹幕文件完成</label
>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-control class="setting-control checkbox">
<label nz-checkbox formControlName="rawDanmakuFileCreated"
>原始弹幕文件创建</label
>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-control class="setting-control checkbox">
<label nz-checkbox formControlName="rawDanmakuFileCompleted"
>原始弹幕文件完成</label
>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-control class="setting-control checkbox">
<label nz-checkbox formControlName="videoPostprocessingCompleted"
>视频后处理完成</label
> >
</nz-form-control> </nz-form-control>
</nz-form-item> </nz-form-item>

View File

@ -21,8 +21,17 @@ const DEFAULT_SETTINGS = {
liveBegan: true, liveBegan: true,
liveEnded: true, liveEnded: true,
roomChange: true, roomChange: true,
recordingStarted: true,
recordingFinished: true,
recordingCancelled: true,
videoFileCreated: true,
videoFileCompleted: true,
danmakuFileCreated: true,
danmakuFileCompleted: true,
rawDanmakuFileCreated: true,
rawDanmakuFileCompleted: true,
videoPostprocessingCompleted: true,
spaceNoEnough: true, spaceNoEnough: true,
fileCompleted: true,
errorOccurred: true, errorOccurred: true,
} as const; } as const;
@ -57,7 +66,16 @@ export class WebhookEditDialogComponent implements OnChanges {
liveBegan: [''], liveBegan: [''],
liveEnded: [''], liveEnded: [''],
roomChange: [''], roomChange: [''],
fileCompleted: [''], recordingStarted: [''],
recordingFinished: [''],
recordingCancelled: [''],
videoFileCreated: [''],
videoFileCompleted: [''],
danmakuFileCreated: [''],
danmakuFileCompleted: [''],
rawDanmakuFileCreated: [''],
rawDanmakuFileCompleted: [''],
videoPostprocessingCompleted: [''],
spaceNoEnough: [''], spaceNoEnough: [''],
errorOccurred: [''], errorOccurred: [''],
}); });

View File

@ -1,5 +1,5 @@
export const breakpoints = [ export const breakpoints = [
'(max-width: 534.98px)', '(max-width: 534.98px)',
'(min-width: 535px) and (max-width: 1059.98px)', '(min-width: 535px) and (max-width: 1199.98px)',
'(min-width: 1060px)', '(min-width: 1200px)',
] as const; ] as const;

View File

@ -259,8 +259,8 @@
class="setting-label" class="setting-label"
nzFor="recordGiftSend" nzFor="recordGiftSend"
nzNoColon nzNoColon
nzTooltipTitle="记录礼信息到弹幕文件里" nzTooltipTitle="记录礼信息到弹幕文件里"
>记录</nz-form-label >记录礼</nz-form-label
> >
<nz-form-control class="setting-control switch"> <nz-form-control class="setting-control switch">
<nz-switch <nz-switch
@ -281,6 +281,33 @@
>覆盖全局设置</label >覆盖全局设置</label
> >
</nz-form-item> </nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label"
nzFor="recordFreeGifts"
nzNoColon
nzTooltipTitle="记录免费礼物信息到弹幕文件里"
>记录免费礼物</nz-form-label
>
<nz-form-control class="setting-control switch">
<nz-switch
id="recordFreeGifts"
name="recordFreeGifts"
[(ngModel)]="model.danmaku.recordFreeGifts"
[disabled]="options.danmaku.recordFreeGifts === null"
></nz-switch>
</nz-form-control>
<label
nz-checkbox
[nzChecked]="options.danmaku.recordFreeGifts !== null"
(nzCheckedChange)="
options.danmaku.recordFreeGifts = $event
? globalSettings.danmaku.recordFreeGifts
: null
"
>覆盖全局设置</label
>
</nz-form-item>
<nz-form-item class="setting-item"> <nz-form-item class="setting-item">
<nz-form-label <nz-form-label
class="setting-label" class="setting-label"

View File

@ -30,6 +30,10 @@
flex: auto; flex: auto;
} }
} }
.selector {
min-width: 6em;
}
} }
.reverse-button { .reverse-button {

View File

@ -53,8 +53,10 @@ export class ToolbarComponent implements OnInit, OnDestroy {
readonly selections = [ readonly selections = [
{ label: '全部', value: DataSelection.ALL }, { label: '全部', value: DataSelection.ALL },
{ label: '录制中', value: DataSelection.RECORDING }, { label: '录制中', value: DataSelection.RECORDING },
{ label: '录制开', value: DataSelection.RECORDER_ENABLED },
{ label: '录制关', value: DataSelection.RECORDER_DISABLED }, { label: '录制关', value: DataSelection.RECORDER_DISABLED },
{ label: '停止', value: DataSelection.STOPPED }, { label: '运行', value: DataSelection.MONITOR_ENABLED },
{ label: '停止', value: DataSelection.MONITOR_DISABLED },
{ label: '直播', value: DataSelection.LIVING }, { label: '直播', value: DataSelection.LIVING },
{ label: '轮播', value: DataSelection.ROUNDING }, { label: '轮播', value: DataSelection.ROUNDING },
{ label: '闲置', value: DataSelection.PREPARING }, { label: '闲置', value: DataSelection.PREPARING },