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
- 修复录制错误: `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'
__version__ = '1.3.2'
__version__ = '1.4.0'
__github__ = 'https://github.com/acgnhiki/blrec'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

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

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):
_SUFFIX_SET = frozenset(('.flv', '.mp4', '.xml', '.meta'))
_SUFFIX_SET = frozenset(('.flv', '.mp4', '.xml', '.jsonl', '.jpg'))
def __init__(
self,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

81
src/blrec/web/security.py Normal file
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,
LiveEndedEvent,
RoomChangeEvent,
RecordingStartedEvent,
RecordingFinishedEvent,
RecordingCancelledEvent,
VideoFileCreatedEvent,
VideoFileCompletedEvent,
DanmakuFileCreatedEvent,
DanmakuFileCompletedEvent,
RawDanmakuFileCreatedEvent,
RawDanmakuFileCompletedEvent,
SpaceNoEnoughEvent,
FileCompletedEvent,
VideoPostprocessingCompletedEvent,
)
from ..event.typing import Event
@ -37,8 +46,26 @@ class WebHook:
types.add(LiveEndedEvent)
if settings.room_change:
types.add(RoomChangeEvent)
if settings.file_completed:
types.add(FileCompletedEvent)
if settings.recording_started:
types.add(RecordingStartedEvent)
if settings.recording_finished:
types.add(RecordingFinishedEvent)
if settings.recording_cancelled:
types.add(RecordingCancelledEvent)
if settings.video_file_created:
types.add(VideoFileCreatedEvent)
if settings.video_file_completed:
types.add(VideoFileCompletedEvent)
if settings.danmaku_file_created:
types.add(DanmakuFileCreatedEvent)
if settings.danmaku_file_completed:
types.add(DanmakuFileCompletedEvent)
if settings.raw_danmaku_file_created:
types.add(RawDanmakuFileCreatedEvent)
if settings.raw_danmaku_file_completed:
types.add(RawDanmakuFileCompletedEvent)
if settings.video_postprocessing_completed:
types.add(VideoPostprocessingCompletedEvent)
if settings.space_no_enough:
types.add(SpaceNoEnoughEvent)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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