Compare commits
5 Commits
v1.3.0
...
v1.6.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c9887a2a8 | ||
|
|
93d93888e4 | ||
|
|
34b10a8a2d | ||
|
|
451e744ceb | ||
|
|
2f65aa54ca |
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
*/__pycache__*
|
||||
*/.mypy_cache*
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -1,5 +1,35 @@
|
||||
# 更新日志
|
||||
|
||||
## 1.6.0-alpha
|
||||
|
||||
- REST API 支持获取正在录制的 flv 文件的路径和元数据
|
||||
|
||||
## 1.5.0
|
||||
|
||||
- 支持设置日志文件存放位置
|
||||
- Docker 支持修改日志文件和录播文件存放位置
|
||||
|
||||
## 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`
|
||||
- 修复前端 https 下不能显示主播头像
|
||||
|
||||
## 1.3.1
|
||||
|
||||
- 修复没成功修复的录制异常 `IndexError: list index out of range`
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### 功能
|
||||
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.10-slim-buster
|
||||
|
||||
WORKDIR /app
|
||||
VOLUME ["/cfg", "/log", "/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
|
||||
|
||||
ENV DEFAULT_SETTINGS_FILE=/cfg/settings.toml
|
||||
ENV DEFAULT_LOG_DIR=/log
|
||||
ENV DEFAULT_OUT_DIR=/rec
|
||||
|
||||
EXPOSE 2233
|
||||
ENTRYPOINT ["blrec", "--host", "0.0.0.0"]
|
||||
CMD []
|
||||
26
Dockerfile.mirrors
Normal file
26
Dockerfile.mirrors
Normal file
@@ -0,0 +1,26 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.10-slim-buster
|
||||
|
||||
WORKDIR /app
|
||||
VOLUME ["/cfg", "/log", "/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
|
||||
|
||||
ENV DEFAULT_SETTINGS_FILE=/cfg/settings.toml
|
||||
ENV DEFAULT_LOG_DIR=/log
|
||||
ENV DEFAULT_OUT_DIR=/rec
|
||||
|
||||
EXPOSE 2233
|
||||
ENTRYPOINT ["blrec", "--host", "0.0.0.0"]
|
||||
CMD []
|
||||
48
README.md
48
README.md
@@ -72,19 +72,54 @@
|
||||
|
||||
删除解压后的文件夹
|
||||
|
||||
|
||||
## Docker
|
||||
|
||||
### 环境变量
|
||||
|
||||
- 默认设置文件位置: `ENV DEFAULT_SETTINGS_FILE=/cfg/settings.toml`
|
||||
- 默认日志存放目录: `ENV DEFAULT_LOG_DIR=/log`
|
||||
- 默认录播存放目录: `ENV DEFAULT_OUT_DIR=/rec`
|
||||
|
||||
### 默认参数运行
|
||||
|
||||
`sudo docker run -v /etc/blrec:/cfg -v /var/log/blrec:/log -v ~/blrec:/rec -dp 2233:2233 acgnhiki/blrec`
|
||||
|
||||
### 命令行参数用法
|
||||
|
||||
```bash
|
||||
sudo docker run \
|
||||
-v /etc/blrec:/cfg -v /var/log/blrec:/log -v ~/blrec:/rec \
|
||||
-dp 2233:2233 acgnhiki/blrec \
|
||||
-c /cfg/another_settings.toml \
|
||||
--key-file path/to/key-file \
|
||||
--cert-file path/to/cert-file \
|
||||
--api-key bili2233
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 使用默认设置文件和保存位置
|
||||
### 命令行参数用法
|
||||
|
||||
`blrec --help`
|
||||
|
||||
### 默认参数运行
|
||||
|
||||
在命令行终端里执行 `blrec` ,然后浏览器访问 `http://localhost:2233`。
|
||||
|
||||
设置文件为 `toml` 文件,默认位置在 `~/.blrec/settings.toml`。默认录播文件保存位置为当前工作目录 `.`。
|
||||
默认设置文件位置:`~/.blrec/settings.toml`
|
||||
|
||||
### 指定设置文件和保存位置
|
||||
默认日志文件目录: `~/.blrec/logs`
|
||||
|
||||
`blrec -c path/to/settings.toml -o dirpath/to/save/files`
|
||||
默认录播文件目录: `.`
|
||||
|
||||
如果指定的设置文件不存在会自动创建。通过命令行参数指定保存位置会覆盖掉设置文件的设置。
|
||||
### 指定设置文件和录播与日志保存位置
|
||||
|
||||
`blrec -c path/to/settings.toml -o path/to/records --log-dir path/to/logs`
|
||||
|
||||
如果指定的设置文件不存在会自动创建
|
||||
|
||||
**命令行参数会覆盖掉设置文件的对应的设置**
|
||||
|
||||
### 绑定主机和端口
|
||||
|
||||
@@ -117,7 +152,8 @@
|
||||
作为 ASGI 应用运行,参数通过环境变量指定。
|
||||
|
||||
- `config` 指定设置文件
|
||||
- `out_dir` 指定保存位置
|
||||
- `out_dir` 指定录播存放位置
|
||||
- `log_dir` 指定日志存放位置
|
||||
- `api_key` 指定 `api key`
|
||||
|
||||
### bash
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
__prog__ = 'blrec'
|
||||
__version__ = '1.3.0'
|
||||
__version__ = '1.6.0-alpha'
|
||||
__github__ = 'https://github.com/acgnhiki/blrec'
|
||||
|
||||
@@ -7,6 +7,7 @@ import attr
|
||||
import psutil
|
||||
|
||||
from . import __prog__, __version__
|
||||
from .flv.data_analyser import MetaData
|
||||
from .disk_space import SpaceMonitor, SpaceReclaimer
|
||||
from .bili.helpers import ensure_room_id
|
||||
from .task import (
|
||||
@@ -103,7 +104,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 +137,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)
|
||||
@@ -190,6 +202,9 @@ class Application:
|
||||
def get_task_param(self, room_id: int) -> TaskParam:
|
||||
return self._task_manager.get_task_param(room_id)
|
||||
|
||||
def get_task_metadata(self, room_id: int) -> Optional[MetaData]:
|
||||
return self._task_manager.get_task_metadata(room_id)
|
||||
|
||||
def get_task_video_file_details(
|
||||
self, room_id: int
|
||||
) -> Iterator[VideoFileDetail]:
|
||||
|
||||
@@ -68,11 +68,11 @@ class WebApi:
|
||||
params = {
|
||||
'room_id': room_id,
|
||||
'protocol': '0,1',
|
||||
'format': '0,2',
|
||||
'format': '0,1,2',
|
||||
'codec': '0,1',
|
||||
'qn': qn,
|
||||
'platform': 'web',
|
||||
'ptype': 16,
|
||||
'ptype': 8,
|
||||
}
|
||||
r = await self._get(self.GET_ROOM_PLAY_INFO_URL, params=params)
|
||||
return r['data']
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import asyncio
|
||||
import re
|
||||
import json
|
||||
from typing import Dict, List, Optional, cast
|
||||
from typing import Dict, List, cast
|
||||
|
||||
import aiohttp
|
||||
from tenacity import (
|
||||
@@ -165,9 +165,11 @@ class Live:
|
||||
# the timestamp on the server at the moment in seconds
|
||||
return await self._api.get_timestamp()
|
||||
|
||||
async def get_live_stream_url(
|
||||
self, qn: QualityNumber = 10000, format: StreamFormat = 'flv'
|
||||
) -> Optional[str]:
|
||||
async def get_live_stream_urls(
|
||||
self,
|
||||
qn: QualityNumber = 10000,
|
||||
format: StreamFormat = 'flv',
|
||||
) -> List[str]:
|
||||
try:
|
||||
data = await self._api.get_room_play_info(self._room_id, qn)
|
||||
except Exception:
|
||||
@@ -188,12 +190,13 @@ class Live:
|
||||
accept_qn = cast(List[QualityNumber], codec['accept_qn'])
|
||||
|
||||
if qn not in accept_qn:
|
||||
return None
|
||||
|
||||
return []
|
||||
assert codec['current_qn'] == qn
|
||||
url_info = codec['url_info'][0]
|
||||
|
||||
return url_info['host'] + codec['base_url'] + url_info['extra']
|
||||
return [
|
||||
i['host'] + codec['base_url'] + i['extra']
|
||||
for i in codec['url_info']
|
||||
]
|
||||
|
||||
def _check_room_play_info(self, data: ResponseData) -> None:
|
||||
if data['is_hidden']:
|
||||
|
||||
@@ -8,6 +8,7 @@ from lxml import html
|
||||
from lxml.html.clean import clean_html
|
||||
|
||||
from .typing import ResponseData
|
||||
from ..utils.url import ensure_scheme
|
||||
|
||||
|
||||
__all__ = 'LiveStatus', 'RoomInfo', 'UserInfo'
|
||||
@@ -38,9 +39,9 @@ class RoomInfo:
|
||||
|
||||
@staticmethod
|
||||
def from_data(data: ResponseData) -> 'RoomInfo':
|
||||
if (timestamp := data.get('live_start_time', None)) is not None:
|
||||
if (timestamp := data.get('live_start_time')) is not None:
|
||||
live_start_time = cast(int, timestamp)
|
||||
elif (time_string := data.get('live_time', None)) is not None:
|
||||
elif (time_string := data.get('live_time')) is not None:
|
||||
if time_string == '0000-00-00 00:00:00':
|
||||
live_start_time = 0
|
||||
else:
|
||||
@@ -49,6 +50,9 @@ class RoomInfo:
|
||||
else:
|
||||
raise ValueError(f'Failed to init live_start_time: {data}')
|
||||
|
||||
if (cover := data.get('cover') or data.get('user_cover', '')):
|
||||
cover = ensure_scheme(cover, 'https')
|
||||
|
||||
if (description := data['description']):
|
||||
description = re.sub(r'<br\s*/?>', '\n', description)
|
||||
tree = html.fromstring(description)
|
||||
@@ -66,7 +70,7 @@ class RoomInfo:
|
||||
live_start_time=live_start_time,
|
||||
online=int(data['online']),
|
||||
title=data['title'],
|
||||
cover=data.get('cover', None) or data.get('user_cover', None),
|
||||
cover=cover,
|
||||
tags=data['tags'],
|
||||
description=description,
|
||||
)
|
||||
@@ -86,7 +90,7 @@ class UserInfo:
|
||||
return UserInfo(
|
||||
name=data['name'],
|
||||
gender=data['sex'],
|
||||
face=data['face'],
|
||||
face=ensure_scheme(data['face'], 'https'),
|
||||
uid=data['mid'],
|
||||
level=data['level'],
|
||||
sign=data['sign'],
|
||||
|
||||
@@ -15,6 +15,7 @@ QualityNumber = Literal[
|
||||
|
||||
StreamFormat = Literal[
|
||||
'flv',
|
||||
'ts',
|
||||
'fmp4',
|
||||
]
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import typer
|
||||
|
||||
from .. import __prog__, __version__
|
||||
from ..logging import TqdmOutputStream
|
||||
from ..setting import DEFAULT_SETTINGS_PATH
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,16 +32,21 @@ def cli_main(
|
||||
help=f"show {__prog__}'s version and exit",
|
||||
),
|
||||
config: str = typer.Option(
|
||||
'~/.blrec/settings.toml',
|
||||
None,
|
||||
'--config',
|
||||
'-c',
|
||||
help='path of setting file',
|
||||
help='path of settings.toml file',
|
||||
),
|
||||
out_dir: Optional[str] = typer.Option(
|
||||
None,
|
||||
'--out-dir',
|
||||
'-o',
|
||||
help='path of directory to save files (overwrite setting)'
|
||||
help='path of directory to store record files (overwrite setting)'
|
||||
),
|
||||
log_dir: Optional[str] = typer.Option(
|
||||
None,
|
||||
'--log-dir',
|
||||
help='path of directory to store log files (overwrite setting)'
|
||||
),
|
||||
host: str = typer.Option('localhost', help='webapp host bind'),
|
||||
port: int = typer.Option(2233, help='webapp port bind'),
|
||||
@@ -52,12 +56,14 @@ def cli_main(
|
||||
api_key: Optional[str] = typer.Option(None, help='web api key'),
|
||||
) -> None:
|
||||
"""Bilibili live streaming recorder"""
|
||||
if config != DEFAULT_SETTINGS_PATH:
|
||||
if config is not None:
|
||||
os.environ['config'] = config
|
||||
if api_key is not None:
|
||||
os.environ['api_key'] = api_key
|
||||
if out_dir is not None:
|
||||
os.environ['out_dir'] = out_dir
|
||||
if log_dir is not None:
|
||||
os.environ['log_dir'] = log_dir
|
||||
|
||||
if open:
|
||||
typer.launch(f'http://localhost:{port}')
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -92,6 +92,7 @@ class SuperChatMsg:
|
||||
gift_name: str
|
||||
count: int
|
||||
price: int
|
||||
rate: int
|
||||
time: int # duration in seconds
|
||||
message: str
|
||||
uid: int
|
||||
@@ -105,6 +106,7 @@ class SuperChatMsg:
|
||||
gift_name=data['gift']['gift_name'],
|
||||
count=int(data['gift']['num']),
|
||||
price=int(data['price']),
|
||||
rate=int(data['rate']),
|
||||
time=int(data['time']),
|
||||
message=data['message'],
|
||||
uid=int(data['uid']),
|
||||
|
||||
@@ -4,22 +4,41 @@ import logging
|
||||
from contextlib import suppress
|
||||
|
||||
import aiofiles
|
||||
from aiofiles.threadpool.text import AsyncTextIOWrapper
|
||||
from tenacity import (
|
||||
AsyncRetrying,
|
||||
stop_after_attempt,
|
||||
retry_if_not_exception_type,
|
||||
)
|
||||
|
||||
from .raw_danmaku_receiver import RawDanmakuReceiver
|
||||
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
||||
from ..exception import exception_callback
|
||||
from ..exception import exception_callback, submit_exception
|
||||
from ..event.event_emitter import EventListener, EventEmitter
|
||||
from ..path import raw_danmaku_path
|
||||
from ..utils.mixins import SwitchableMixin
|
||||
from ..logging.room_id import aio_task_with_room_id
|
||||
|
||||
|
||||
__all__ = 'RawDanmakuDumper',
|
||||
__all__ = 'RawDanmakuDumper', 'RawDanmakuDumperEventListener'
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RawDanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
|
||||
class RawDanmakuDumperEventListener(EventListener):
|
||||
async def on_raw_danmaku_file_created(self, path: str) -> None:
|
||||
...
|
||||
|
||||
async def on_raw_danmaku_file_completed(self, path: str) -> None:
|
||||
...
|
||||
|
||||
|
||||
class RawDanmakuDumper(
|
||||
EventEmitter[RawDanmakuDumperEventListener],
|
||||
StreamRecorderEventListener,
|
||||
SwitchableMixin,
|
||||
):
|
||||
def __init__(
|
||||
self,
|
||||
stream_recorder: StreamRecorder,
|
||||
@@ -53,7 +72,7 @@ class RawDanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
|
||||
await self._cancel_dump_task()
|
||||
|
||||
def _create_dump_task(self) -> None:
|
||||
self._dump_task = asyncio.create_task(self._dump())
|
||||
self._dump_task = asyncio.create_task(self._do_dump())
|
||||
self._dump_task.add_done_callback(exception_callback)
|
||||
|
||||
async def _cancel_dump_task(self) -> None:
|
||||
@@ -62,16 +81,36 @@ class RawDanmakuDumper(StreamRecorderEventListener, SwitchableMixin):
|
||||
await self._dump_task
|
||||
|
||||
@aio_task_with_room_id
|
||||
async def _dump(self) -> None:
|
||||
async def _do_dump(self) -> None:
|
||||
logger.debug('Started dumping raw danmaku')
|
||||
try:
|
||||
async with aiofiles.open(self._path, 'wt', encoding='utf8') as f:
|
||||
logger.info(f"Raw danmaku file created: '{self._path}'")
|
||||
await self._emit('raw_danmaku_file_created', self._path)
|
||||
|
||||
async for attempt in AsyncRetrying(
|
||||
retry=retry_if_not_exception_type((
|
||||
asyncio.CancelledError
|
||||
)),
|
||||
stop=stop_after_attempt(3),
|
||||
):
|
||||
with attempt:
|
||||
try:
|
||||
await self._dumping_loop(f)
|
||||
except Exception as e:
|
||||
submit_exception(e)
|
||||
raise
|
||||
while True:
|
||||
danmu = await self._receiver.get_raw_danmaku()
|
||||
json_string = json.dumps(danmu, ensure_ascii=False)
|
||||
await f.write(json_string + '\n')
|
||||
finally:
|
||||
logger.info(f"Raw danmaku file completed: '{self._path}'")
|
||||
await self._emit('raw_danmaku_file_completed', self._path)
|
||||
logger.debug('Stopped dumping raw danmaku')
|
||||
|
||||
async def _dumping_loop(self, file: AsyncTextIOWrapper) -> None:
|
||||
while True:
|
||||
danmu = await self._receiver.get_raw_danmaku()
|
||||
json_string = json.dumps(danmu, ensure_ascii=False)
|
||||
await file.write(json_string + '\n')
|
||||
|
||||
@@ -11,9 +11,10 @@ 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 ..flv.data_analyser import MetaData
|
||||
from ..bili.live import Live
|
||||
from ..bili.models import RoomInfo
|
||||
from ..bili.danmaku_client import DanmakuClient
|
||||
@@ -41,17 +42,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 +77,7 @@ class Recorder(
|
||||
LiveEventListener,
|
||||
AsyncStoppableMixin,
|
||||
DanmakuDumperEventListener,
|
||||
RawDanmakuDumperEventListener,
|
||||
StreamRecorderEventListener,
|
||||
):
|
||||
def __init__(
|
||||
@@ -75,6 +93,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 +129,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 +139,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 +199,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
|
||||
@@ -243,9 +275,18 @@ class Recorder(
|
||||
def duration_limit(self, value: int) -> None:
|
||||
self._stream_recorder.duration_limit = value
|
||||
|
||||
@property
|
||||
def recording_path(self) -> Optional[str]:
|
||||
return self._stream_recorder.recording_path
|
||||
|
||||
@property
|
||||
def metadata(self) -> Optional[MetaData]:
|
||||
return self._stream_recorder.metadata
|
||||
|
||||
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 +300,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 +348,24 @@ class Recorder(
|
||||
async def on_video_file_created(
|
||||
self, path: str, record_start_time: int
|
||||
) -> None:
|
||||
await self._emit('video_file_created', path, record_start_time)
|
||||
await self._emit('video_file_created', self, path)
|
||||
|
||||
async def on_video_file_completed(self, path: str) -> None:
|
||||
await self._emit('video_file_completed', path)
|
||||
await self._emit('video_file_completed', self, path)
|
||||
if self.save_cover:
|
||||
await self._save_cover_image(path)
|
||||
|
||||
async def on_danmaku_file_created(self, path: str) -> None:
|
||||
await self._emit('danmaku_file_created', path)
|
||||
await self._emit('danmaku_file_created', self, path)
|
||||
|
||||
async def on_danmaku_file_completed(self, path: str) -> None:
|
||||
await self._emit('danmaku_file_completed', path)
|
||||
await self._emit('danmaku_file_completed', self, path)
|
||||
|
||||
async def on_raw_danmaku_file_created(self, path: str) -> None:
|
||||
await self._emit('raw_danmaku_file_created', self, path)
|
||||
|
||||
async def on_raw_danmaku_file_completed(self, path: str) -> None:
|
||||
await self._emit('raw_danmaku_file_completed', self, path)
|
||||
|
||||
async def on_stream_recording_stopped(self) -> None:
|
||||
logger.debug('Stream recording stopped')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import errno
|
||||
import asyncio
|
||||
@@ -25,8 +26,8 @@ from tenacity import (
|
||||
stop_after_delay,
|
||||
stop_after_attempt,
|
||||
retry_if_result,
|
||||
retry_if_exception,
|
||||
retry_if_exception_type,
|
||||
retry_if_not_exception_type,
|
||||
Retrying,
|
||||
TryAgain,
|
||||
)
|
||||
@@ -37,10 +38,11 @@ from .statistics import StatisticsCalculator
|
||||
from ..event.event_emitter import EventListener, EventEmitter
|
||||
from ..bili.live import Live
|
||||
from ..bili.typing import QualityNumber
|
||||
from ..flv.data_analyser import MetaData
|
||||
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
|
||||
)
|
||||
@@ -185,6 +187,13 @@ class StreamRecorder(
|
||||
def recording_path(self) -> Optional[str]:
|
||||
return self._file_manager.curr_path
|
||||
|
||||
@property
|
||||
def metadata(self) -> Optional[MetaData]:
|
||||
if self._stream_processor is not None:
|
||||
return self._stream_processor.metadata
|
||||
else:
|
||||
return None
|
||||
|
||||
def has_file(self) -> bool:
|
||||
return self._file_manager.has_file()
|
||||
|
||||
@@ -224,6 +233,7 @@ class StreamRecorder(
|
||||
|
||||
def _run(self) -> None:
|
||||
self._calculator.reset()
|
||||
self._use_candidate_stream: bool = False
|
||||
try:
|
||||
with tqdm(
|
||||
desc='Recording',
|
||||
@@ -268,7 +278,7 @@ class StreamRecorder(
|
||||
reraise=True,
|
||||
retry=(
|
||||
retry_if_result(lambda r: not self._stopped) |
|
||||
retry_if_exception(lambda e: not isinstance(e, OSError))
|
||||
retry_if_not_exception_type((OSError, NotImplementedError))
|
||||
),
|
||||
wait=wait_exponential_for_same_exceptions(max=60),
|
||||
before_sleep=before_sleep_log(logger, logging.DEBUG, 'main_loop'),
|
||||
@@ -297,6 +307,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 +344,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 +383,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 +401,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 +574,17 @@ class OutputFileManager(BaseOutputFileManager, AsyncCooperationMix):
|
||||
second=str(date_time.second).rjust(2, '0'),
|
||||
)
|
||||
|
||||
full_pathname = os.path.abspath(
|
||||
pathname = os.path.abspath(
|
||||
os.path.expanduser(os.path.join(self.out_dir, relpath) + '.flv')
|
||||
)
|
||||
os.makedirs(os.path.dirname(full_pathname), exist_ok=True)
|
||||
os.makedirs(os.path.dirname(pathname), exist_ok=True)
|
||||
while os.path.exists(pathname):
|
||||
root, ext = os.path.splitext(pathname)
|
||||
m = re.search(r'_\((\d+)\)$', root)
|
||||
if m is None:
|
||||
root += '_(1)'
|
||||
else:
|
||||
root = re.sub(r'\(\d+\)$', f'({int(m.group(1)) + 1})', root)
|
||||
pathname = root + ext
|
||||
|
||||
return full_pathname
|
||||
return pathname
|
||||
|
||||
@@ -156,13 +156,22 @@ class DanmakuWriter:
|
||||
'uid': str(dm.uid),
|
||||
'user': dm.uname,
|
||||
}
|
||||
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:
|
||||
|
||||
@@ -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
1
src/blrec/data/webapp/694.d4844204c9f8d279.js
Normal file
1
src/blrec/data/webapp/694.d4844204c9f8d279.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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.dbc624475730f362.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.042620305008901b.js" type="module"></script>
|
||||
|
||||
</body></html>
|
||||
1
src/blrec/data/webapp/main.042620305008901b.js
Normal file
1
src/blrec/data/webapp/main.042620305008901b.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"configVersion": 1,
|
||||
"timestamp": 1642737806856,
|
||||
"timestamp": 1645421972060,
|
||||
"index": "/index.html",
|
||||
"assetGroups": [
|
||||
{
|
||||
@@ -11,17 +11,17 @@
|
||||
"ignoreVary": true
|
||||
},
|
||||
"urls": [
|
||||
"/103.9c8251484169c949.js",
|
||||
"/103.5b5d2a6e5a8a7479.js",
|
||||
"/146.92e3b29c4c754544.js",
|
||||
"/622.dd6c6ac77555edc7.js",
|
||||
"/66.97582e026891bf70.js",
|
||||
"/853.5697121b2e654d67.js",
|
||||
"/694.d4844204c9f8d279.js",
|
||||
"/853.84ee7e1d7cff8913.js",
|
||||
"/common.858f777e9296e6f2.js",
|
||||
"/index.html",
|
||||
"/main.7a4da5a70e652c3f.js",
|
||||
"/main.042620305008901b.js",
|
||||
"/manifest.webmanifest",
|
||||
"/polyfills.4b08448aee19bb22.js",
|
||||
"/runtime.c48b962c8225f379.js",
|
||||
"/runtime.dbc624475730f362.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",
|
||||
"/66.97582e026891bf70.js": "11cfd8acd3399fef42f0cf77d64aafc62c7e6994",
|
||||
"/853.5697121b2e654d67.js": "beb26b99743f6363c59c477b18be790b1e70d56e",
|
||||
"/694.d4844204c9f8d279.js": "513c6b68a84ad47494a7397a06194c5136da3adc",
|
||||
"/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": "4a8198a30590a4863ef700f1c541a0fce551e8c1",
|
||||
"/main.042620305008901b.js": "03d1b5d12f588193841fdc44913ec20625404c7c",
|
||||
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
|
||||
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
|
||||
"/runtime.c48b962c8225f379.js": "ae674dc840fe5aa957d08253f56e21c4b772b93b",
|
||||
"/runtime.dbc624475730f362.js": "030dff9e7d735e03d87257f33e8167d467d99adb",
|
||||
"/styles.1f581691b230dc4d.css": "6f5befbbad57c2b2e80aae855139744b8010d150"
|
||||
},
|
||||
"navigationUrls": [
|
||||
|
||||
@@ -1 +1 @@
|
||||
(()=>{"use strict";var e,v={},m={};function r(e){var i=m[e];if(void 0!==i)return i.exports;var t=m[e]={exports:{}};return v[e].call(t.exports,t,t.exports,r),t.exports}r.m=v,e=[],r.O=(i,t,o,f)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,o,f]=e[n],c=!0,l=0;l<t.length;l++)(!1&f||a>=f)&&Object.keys(r.O).every(b=>r.O[b](t[l]))?t.splice(l--,1):(c=!1,f<a&&(a=f));if(c){e.splice(n--,1);var d=o();void 0!==d&&(i=d)}}return i}f=f||0;for(var n=e.length;n>0&&e[n-1][2]>f;n--)e[n]=e[n-1];e[n]=[t,o,f]},r.n=e=>{var i=e&&e.__esModule?()=>e.default:()=>e;return r.d(i,{a:i}),i},r.d=(e,i)=>{for(var t in i)r.o(i,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:i[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((i,t)=>(r.f[t](e,i),i),[])),r.u=e=>(592===e?"common":e)+"."+{66:"97582e026891bf70",103:"9c8251484169c949",146:"92e3b29c4c754544",592:"858f777e9296e6f2",622:"dd6c6ac77555edc7",853:"5697121b2e654d67"}[e]+".js",r.miniCssF=e=>{},r.o=(e,i)=>Object.prototype.hasOwnProperty.call(e,i),(()=>{var e={},i="blrec:";r.l=(t,o,f,n)=>{if(e[t])e[t].push(o);else{var a,c;if(void 0!==f)for(var l=document.getElementsByTagName("script"),d=0;d<l.length;d++){var u=l[d];if(u.getAttribute("src")==t||u.getAttribute("data-webpack")==i+f){a=u;break}}a||(c=!0,(a=document.createElement("script")).type="module",a.charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",i+f),a.src=r.tu(t)),e[t]=[o];var s=(g,b)=>{a.onerror=a.onload=null,clearTimeout(p);var _=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),_&&_.forEach(h=>h(b)),g)return g(b)},p=setTimeout(s.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=s.bind(null,a.onerror),a.onload=s.bind(null,a.onload),c&&document.head.appendChild(a)}}})(),r.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tu=i=>(void 0===e&&(e={createScriptURL:t=>t},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e.createScriptURL(i))})(),r.p="",(()=>{var e={666:0};r.f.j=(o,f)=>{var n=r.o(e,o)?e[o]:void 0;if(0!==n)if(n)f.push(n[2]);else if(666!=o){var a=new Promise((u,s)=>n=e[o]=[u,s]);f.push(n[2]=a);var c=r.p+r.u(o),l=new Error;r.l(c,u=>{if(r.o(e,o)&&(0!==(n=e[o])&&(e[o]=void 0),n)){var s=u&&("load"===u.type?"missing":u.type),p=u&&u.target&&u.target.src;l.message="Loading chunk "+o+" failed.\n("+s+": "+p+")",l.name="ChunkLoadError",l.type=s,l.request=p,n[1](l)}},"chunk-"+o,o)}else e[o]=0},r.O.j=o=>0===e[o];var i=(o,f)=>{var l,d,[n,a,c]=f,u=0;if(n.some(p=>0!==e[p])){for(l in a)r.o(a,l)&&(r.m[l]=a[l]);if(c)var s=c(r)}for(o&&o(f);u<n.length;u++)r.o(e,d=n[u])&&e[d]&&e[d][0](),e[n[u]]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(i.bind(null,0)),t.push=i.bind(null,t.push.bind(t))})()})();
|
||||
(()=>{"use strict";var e,v={},m={};function r(e){var i=m[e];if(void 0!==i)return i.exports;var t=m[e]={exports:{}};return v[e].call(t.exports,t,t.exports,r),t.exports}r.m=v,e=[],r.O=(i,t,o,f)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,o,f]=e[n],c=!0,d=0;d<t.length;d++)(!1&f||a>=f)&&Object.keys(r.O).every(p=>r.O[p](t[d]))?t.splice(d--,1):(c=!1,f<a&&(a=f));if(c){e.splice(n--,1);var u=o();void 0!==u&&(i=u)}}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",694:"d4844204c9f8d279",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 d=document.getElementsByTagName("script"),u=0;u<d.length;u++){var l=d[u];if(l.getAttribute("src")==t||l.getAttribute("data-webpack")==i+f){a=l;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,p)=>{a.onerror=a.onload=null,clearTimeout(b);var _=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),_&&_.forEach(h=>h(p)),g)return g(p)},b=setTimeout(s.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=s.bind(null,a.onerror),a.onload=s.bind(null,a.onload),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((l,s)=>n=e[o]=[l,s]);f.push(n[2]=a);var c=r.p+r.u(o),d=new Error;r.l(c,l=>{if(r.o(e,o)&&(0!==(n=e[o])&&(e[o]=void 0),n)){var s=l&&("load"===l.type?"missing":l.type),b=l&&l.target&&l.target.src;d.message="Loading chunk "+o+" failed.\n("+s+": "+b+")",d.name="ChunkLoadError",d.type=s,d.request=b,n[1](d)}},"chunk-"+o,o)}else e[o]=0},r.O.j=o=>0===e[o];var i=(o,f)=>{var d,u,[n,a,c]=f,l=0;if(n.some(b=>0!==e[b])){for(d in a)r.o(a,d)&&(r.m[d]=a[d]);if(c)var s=c(r)}for(o&&o(f);l<n.length;l++)r.o(e,u=n[l])&&e[u]&&e[u][0](),e[n[l]]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(i.bind(null,0)),t.push=i.bind(null,t.push.bind(t))})()})();
|
||||
@@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SpaceReclaimer(SpaceEventListener, SwitchableMixin):
|
||||
_SUFFIX_SET = frozenset(('.flv', '.mp4', '.xml', '.meta'))
|
||||
_SUFFIX_SET = frozenset(('.flv', '.mp4', '.xml', '.jsonl', '.jpg'))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -9,10 +9,28 @@ from .models import (
|
||||
LiveEndedEventData,
|
||||
RoomChangeEvent,
|
||||
RoomChangeEventData,
|
||||
RecordingStartedEvent,
|
||||
RecordingStartedEventData,
|
||||
RecordingFinishedEvent,
|
||||
RecordingFinishedEventData,
|
||||
RecordingCancelledEvent,
|
||||
RecordingCancelledEventData,
|
||||
VideoFileCreatedEvent,
|
||||
VideoFileCreatedEventData,
|
||||
VideoFileCompletedEvent,
|
||||
VideoFileCompletedEventData,
|
||||
DanmakuFileCreatedEvent,
|
||||
DanmakuFileCreatedEventData,
|
||||
DanmakuFileCompletedEvent,
|
||||
DanmakuFileCompletedEventData,
|
||||
RawDanmakuFileCreatedEvent,
|
||||
RawDanmakuFileCreatedEventData,
|
||||
RawDanmakuFileCompletedEvent,
|
||||
RawDanmakuFileCompletedEventData,
|
||||
VideoPostprocessingCompletedEvent,
|
||||
VideoPostprocessingCompletedEventData,
|
||||
SpaceNoEnoughEvent,
|
||||
SpaceNoEnoughEventData,
|
||||
FileCompletedEvent,
|
||||
FileCompletedEventData,
|
||||
Error,
|
||||
ErrorData,
|
||||
)
|
||||
@@ -31,10 +49,28 @@ __all__ = (
|
||||
'LiveEndedEventData',
|
||||
'RoomChangeEvent',
|
||||
'RoomChangeEventData',
|
||||
'RecordingStartedEvent',
|
||||
'RecordingStartedEventData',
|
||||
'RecordingFinishedEvent',
|
||||
'RecordingFinishedEventData',
|
||||
'RecordingCancelledEvent',
|
||||
'RecordingCancelledEventData',
|
||||
'VideoFileCreatedEvent',
|
||||
'VideoFileCreatedEventData',
|
||||
'VideoFileCompletedEvent',
|
||||
'VideoFileCompletedEventData',
|
||||
'DanmakuFileCreatedEvent',
|
||||
'DanmakuFileCreatedEventData',
|
||||
'DanmakuFileCompletedEvent',
|
||||
'DanmakuFileCompletedEventData',
|
||||
'RawDanmakuFileCreatedEvent',
|
||||
'RawDanmakuFileCreatedEventData',
|
||||
'RawDanmakuFileCompletedEvent',
|
||||
'RawDanmakuFileCompletedEventData',
|
||||
'VideoPostprocessingCompletedEvent',
|
||||
'VideoPostprocessingCompletedEventData',
|
||||
'SpaceNoEnoughEvent',
|
||||
'SpaceNoEnoughEventData',
|
||||
'FileCompletedEvent',
|
||||
'FileCompletedEventData',
|
||||
'Error',
|
||||
'ErrorData',
|
||||
)
|
||||
|
||||
@@ -9,18 +9,38 @@ from .models import (
|
||||
LiveEndedEventData,
|
||||
RoomChangeEvent,
|
||||
RoomChangeEventData,
|
||||
FileCompletedEvent,
|
||||
FileCompletedEventData,
|
||||
RecordingStartedEvent,
|
||||
RecordingStartedEventData,
|
||||
RecordingFinishedEvent,
|
||||
RecordingFinishedEventData,
|
||||
RecordingCancelledEvent,
|
||||
RecordingCancelledEventData,
|
||||
VideoFileCreatedEvent,
|
||||
VideoFileCreatedEventData,
|
||||
VideoFileCompletedEvent,
|
||||
VideoFileCompletedEventData,
|
||||
DanmakuFileCreatedEvent,
|
||||
DanmakuFileCreatedEventData,
|
||||
DanmakuFileCompletedEvent,
|
||||
DanmakuFileCompletedEventData,
|
||||
RawDanmakuFileCreatedEvent,
|
||||
RawDanmakuFileCreatedEventData,
|
||||
RawDanmakuFileCompletedEvent,
|
||||
RawDanmakuFileCompletedEventData,
|
||||
VideoPostprocessingCompletedEvent,
|
||||
VideoPostprocessingCompletedEventData,
|
||||
SpaceNoEnoughEvent,
|
||||
SpaceNoEnoughEventData,
|
||||
)
|
||||
from ..bili.live import Live
|
||||
from ..bili.models import RoomInfo
|
||||
from ..bili.live_monitor import LiveEventListener
|
||||
from ..core.recorder import RecorderEventListener
|
||||
from ..disk_space import SpaceEventListener
|
||||
from ..postprocess import PostprocessorEventListener
|
||||
if TYPE_CHECKING:
|
||||
from ..bili.live_monitor import LiveMonitor
|
||||
from ..core.recorder import Recorder
|
||||
from ..disk_space import SpaceMonitor, DiskUsage
|
||||
from ..postprocess import Postprocessor
|
||||
|
||||
@@ -52,6 +72,74 @@ class LiveEventSubmitter(LiveEventListener):
|
||||
event_center.submit(RoomChangeEvent.from_data(data))
|
||||
|
||||
|
||||
class RecorderEventSubmitter(RecorderEventListener):
|
||||
def __init__(self, recorder: Recorder) -> None:
|
||||
super().__init__()
|
||||
recorder.add_listener(self)
|
||||
|
||||
async def on_recording_started(self, recorder: Recorder) -> None:
|
||||
data = RecordingStartedEventData(recorder.live.room_info)
|
||||
event_center.submit(RecordingStartedEvent.from_data(data))
|
||||
|
||||
async def on_recording_finished(self, recorder: Recorder) -> None:
|
||||
data = RecordingFinishedEventData(recorder.live.room_info)
|
||||
event_center.submit(RecordingFinishedEvent.from_data(data))
|
||||
|
||||
async def on_recording_cancelled(self, recorder: Recorder) -> None:
|
||||
data = RecordingCancelledEventData(recorder.live.room_info)
|
||||
event_center.submit(RecordingCancelledEvent.from_data(data))
|
||||
|
||||
async def on_video_file_created(
|
||||
self, recorder: Recorder, path: str
|
||||
) -> None:
|
||||
data = VideoFileCreatedEventData(recorder.live.room_id, path)
|
||||
event_center.submit(VideoFileCreatedEvent.from_data(data))
|
||||
|
||||
async def on_video_file_completed(
|
||||
self, recorder: Recorder, path: str
|
||||
) -> None:
|
||||
data = VideoFileCompletedEventData(recorder.live.room_id, path)
|
||||
event_center.submit(VideoFileCompletedEvent.from_data(data))
|
||||
|
||||
async def on_danmaku_file_created(
|
||||
self, recorder: Recorder, path: str
|
||||
) -> None:
|
||||
data = DanmakuFileCreatedEventData(recorder.live.room_id, path)
|
||||
event_center.submit(DanmakuFileCreatedEvent.from_data(data))
|
||||
|
||||
async def on_danmaku_file_completed(
|
||||
self, recorder: Recorder, path: str
|
||||
) -> None:
|
||||
data = DanmakuFileCompletedEventData(recorder.live.room_id, path)
|
||||
event_center.submit(DanmakuFileCompletedEvent.from_data(data))
|
||||
|
||||
async def on_raw_danmaku_file_created(
|
||||
self, recorder: Recorder, path: str
|
||||
) -> None:
|
||||
data = RawDanmakuFileCreatedEventData(recorder.live.room_id, path)
|
||||
event_center.submit(RawDanmakuFileCreatedEvent.from_data(data))
|
||||
|
||||
async def on_raw_danmaku_file_completed(
|
||||
self, recorder: Recorder, path: str
|
||||
) -> None:
|
||||
data = RawDanmakuFileCompletedEventData(recorder.live.room_id, path)
|
||||
event_center.submit(RawDanmakuFileCompletedEvent.from_data(data))
|
||||
|
||||
|
||||
class PostprocessorEventSubmitter(PostprocessorEventListener):
|
||||
def __init__(self, postprocessor: Postprocessor) -> None:
|
||||
super().__init__()
|
||||
postprocessor.add_listener(self)
|
||||
|
||||
async def on_video_postprocessing_completed(
|
||||
self, postprocessor: Postprocessor, path: str
|
||||
) -> None:
|
||||
data = VideoPostprocessingCompletedEventData(
|
||||
postprocessor.recorder.live.room_id, path
|
||||
)
|
||||
event_center.submit(VideoPostprocessingCompletedEvent.from_data(data))
|
||||
|
||||
|
||||
class SpaceEventSubmitter(SpaceEventListener):
|
||||
def __init__(self, space_monitor: SpaceMonitor) -> None:
|
||||
super().__init__()
|
||||
@@ -62,13 +150,3 @@ class SpaceEventSubmitter(SpaceEventListener):
|
||||
) -> None:
|
||||
data = SpaceNoEnoughEventData(path, threshold, disk_usage)
|
||||
event_center.submit(SpaceNoEnoughEvent.from_data(data))
|
||||
|
||||
|
||||
class PostprocessorEventSubmitter(PostprocessorEventListener):
|
||||
def __init__(self, postprocessor: Postprocessor) -> None:
|
||||
super().__init__()
|
||||
postprocessor.add_listener(self)
|
||||
|
||||
async def on_file_completed(self, room_id: int, path: str) -> None:
|
||||
data = FileCompletedEventData(room_id, path)
|
||||
event_center.submit(FileCompletedEvent.from_data(data))
|
||||
|
||||
@@ -9,6 +9,7 @@ import attr
|
||||
if TYPE_CHECKING:
|
||||
from ..bili.models import UserInfo, RoomInfo
|
||||
from ..disk_space.space_monitor import DiskUsage
|
||||
from ..exception import format_exception
|
||||
|
||||
|
||||
_D = TypeVar('_D', bound='BaseEventData')
|
||||
@@ -80,6 +81,127 @@ class RoomChangeEvent(BaseEvent[RoomChangeEventData]):
|
||||
data: RoomChangeEventData
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class RecordingStartedEventData(BaseEventData):
|
||||
room_info: RoomInfo
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||
class RecordingStartedEvent(BaseEvent[RecordingStartedEventData]):
|
||||
type: str = 'RecordingStartedEvent'
|
||||
data: RecordingStartedEventData
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class RecordingFinishedEventData(BaseEventData):
|
||||
room_info: RoomInfo
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||
class RecordingFinishedEvent(BaseEvent[RecordingFinishedEventData]):
|
||||
type: str = 'RecordingFinishedEvent'
|
||||
data: RecordingFinishedEventData
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class RecordingCancelledEventData(BaseEventData):
|
||||
room_info: RoomInfo
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||
class RecordingCancelledEvent(BaseEvent[RecordingCancelledEventData]):
|
||||
type: str = 'RecordingCancelledEvent'
|
||||
data: RecordingCancelledEventData
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class VideoFileCreatedEventData(BaseEventData):
|
||||
room_id: int
|
||||
path: str
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||
class VideoFileCreatedEvent(BaseEvent[VideoFileCreatedEventData]):
|
||||
type: str = 'VideoFileCreatedEvent'
|
||||
data: VideoFileCreatedEventData
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class VideoFileCompletedEventData(BaseEventData):
|
||||
room_id: int
|
||||
path: str
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||
class VideoFileCompletedEvent(BaseEvent[VideoFileCompletedEventData]):
|
||||
type: str = 'VideoFileCompletedEvent'
|
||||
data: VideoFileCompletedEventData
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class DanmakuFileCreatedEventData(BaseEventData):
|
||||
room_id: int
|
||||
path: str
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||
class DanmakuFileCreatedEvent(BaseEvent[DanmakuFileCreatedEventData]):
|
||||
type: str = 'DanmakuFileCreatedEvent'
|
||||
data: DanmakuFileCreatedEventData
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class DanmakuFileCompletedEventData(BaseEventData):
|
||||
room_id: int
|
||||
path: str
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||
class DanmakuFileCompletedEvent(BaseEvent[DanmakuFileCompletedEventData]):
|
||||
type: str = 'DanmakuFileCompletedEvent'
|
||||
data: DanmakuFileCompletedEventData
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class RawDanmakuFileCreatedEventData(BaseEventData):
|
||||
room_id: int
|
||||
path: str
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||
class RawDanmakuFileCreatedEvent(BaseEvent[RawDanmakuFileCreatedEventData]):
|
||||
type: str = 'RawDanmakuFileCreatedEvent'
|
||||
data: RawDanmakuFileCreatedEventData
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class RawDanmakuFileCompletedEventData(BaseEventData):
|
||||
room_id: int
|
||||
path: str
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||
class RawDanmakuFileCompletedEvent(
|
||||
BaseEvent[RawDanmakuFileCompletedEventData]
|
||||
):
|
||||
type: str = 'RawDanmakuFileCompletedEvent'
|
||||
data: RawDanmakuFileCompletedEventData
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class VideoPostprocessingCompletedEventData(BaseEventData):
|
||||
room_id: int
|
||||
path: str
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||
class VideoPostprocessingCompletedEvent(
|
||||
BaseEvent[VideoPostprocessingCompletedEventData]
|
||||
):
|
||||
type: str = 'VideoPostprocessingCompletedEvent'
|
||||
data: VideoPostprocessingCompletedEventData
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class SpaceNoEnoughEventData(BaseEventData):
|
||||
path: str
|
||||
@@ -93,23 +215,17 @@ class SpaceNoEnoughEvent(BaseEvent[SpaceNoEnoughEventData]):
|
||||
data: SpaceNoEnoughEventData
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class FileCompletedEventData(BaseEventData):
|
||||
room_id: int
|
||||
path: str
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||
class FileCompletedEvent(BaseEvent[FileCompletedEventData]):
|
||||
type: str = 'FileCompletedEvent'
|
||||
data: FileCompletedEventData
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True)
|
||||
class ErrorData(BaseEventData):
|
||||
name: str
|
||||
detail: str
|
||||
|
||||
@classmethod
|
||||
def from_exc(cls, exc: BaseException) -> ErrorData:
|
||||
return cls(
|
||||
name=type(exc).__name__, detail=format_exception(exc),
|
||||
)
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||
class Error(BaseEvent[ErrorData]):
|
||||
|
||||
@@ -8,10 +8,28 @@ from .models import (
|
||||
LiveEndedEventData,
|
||||
RoomChangeEvent,
|
||||
RoomChangeEventData,
|
||||
RecordingStartedEvent,
|
||||
RecordingStartedEventData,
|
||||
RecordingFinishedEvent,
|
||||
RecordingFinishedEventData,
|
||||
RecordingCancelledEvent,
|
||||
RecordingCancelledEventData,
|
||||
VideoFileCreatedEvent,
|
||||
VideoFileCreatedEventData,
|
||||
VideoFileCompletedEvent,
|
||||
VideoFileCompletedEventData,
|
||||
DanmakuFileCreatedEvent,
|
||||
DanmakuFileCreatedEventData,
|
||||
DanmakuFileCompletedEvent,
|
||||
DanmakuFileCompletedEventData,
|
||||
RawDanmakuFileCreatedEvent,
|
||||
RawDanmakuFileCreatedEventData,
|
||||
RawDanmakuFileCompletedEvent,
|
||||
RawDanmakuFileCompletedEventData,
|
||||
VideoPostprocessingCompletedEvent,
|
||||
VideoPostprocessingCompletedEventData,
|
||||
SpaceNoEnoughEvent,
|
||||
SpaceNoEnoughEventData,
|
||||
FileCompletedEvent,
|
||||
FileCompletedEventData,
|
||||
Error,
|
||||
ErrorData,
|
||||
)
|
||||
@@ -21,8 +39,17 @@ Event = Union[
|
||||
LiveBeganEvent,
|
||||
LiveEndedEvent,
|
||||
RoomChangeEvent,
|
||||
RecordingStartedEvent,
|
||||
RecordingFinishedEvent,
|
||||
RecordingCancelledEvent,
|
||||
VideoFileCreatedEvent,
|
||||
VideoFileCompletedEvent,
|
||||
DanmakuFileCreatedEvent,
|
||||
DanmakuFileCompletedEvent,
|
||||
RawDanmakuFileCreatedEvent,
|
||||
RawDanmakuFileCompletedEvent,
|
||||
VideoPostprocessingCompletedEvent,
|
||||
SpaceNoEnoughEvent,
|
||||
FileCompletedEvent,
|
||||
Error,
|
||||
]
|
||||
|
||||
@@ -30,7 +57,16 @@ EventData = Union[
|
||||
LiveBeganEventData,
|
||||
LiveEndedEventData,
|
||||
RoomChangeEventData,
|
||||
RecordingStartedEventData,
|
||||
RecordingFinishedEventData,
|
||||
RecordingCancelledEventData,
|
||||
VideoFileCreatedEventData,
|
||||
VideoFileCompletedEventData,
|
||||
DanmakuFileCreatedEventData,
|
||||
DanmakuFileCompletedEventData,
|
||||
RawDanmakuFileCreatedEventData,
|
||||
RawDanmakuFileCompletedEventData,
|
||||
VideoPostprocessingCompletedEventData,
|
||||
SpaceNoEnoughEventData,
|
||||
FileCompletedEventData,
|
||||
ErrorData,
|
||||
]
|
||||
|
||||
@@ -26,5 +26,4 @@ class ExceptionHandler(SwitchableMixin):
|
||||
|
||||
def _log_exception(self, exc: BaseException) -> None:
|
||||
exc_info = (type(exc), exc, exc.__traceback__)
|
||||
msg = 'Unhandled Exception'
|
||||
logger.critical(msg, exc_info=exc_info)
|
||||
logger.critical(type(exc).__name__, exc_info=exc_info)
|
||||
|
||||
@@ -3,6 +3,4 @@ import traceback
|
||||
|
||||
def format_exception(exc: BaseException) -> str:
|
||||
exc_info = (type(exc), exc, exc.__traceback__)
|
||||
errmsg = 'Unhandled exception in event loop\n'
|
||||
errmsg += ''.join(traceback.format_exception(*exc_info))
|
||||
return errmsg
|
||||
return ''.join(traceback.format_exception(*exc_info))
|
||||
|
||||
@@ -14,7 +14,7 @@ from .models import (
|
||||
from .common import Resolution, is_audio_tag, is_script_tag, is_video_tag
|
||||
|
||||
|
||||
__all__ = 'DataAnalyser',
|
||||
__all__ = 'DataAnalyser', 'MetaData', 'KeyFrames'
|
||||
|
||||
|
||||
@attr.s(auto_attribs=True, slots=True, frozen=True, kw_only=True)
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
|
||||
class FlvDataError(ValueError):
|
||||
...
|
||||
|
||||
|
||||
class FlvHeaderError(FlvDataError):
|
||||
...
|
||||
|
||||
|
||||
class FlvTagError(FlvDataError):
|
||||
...
|
||||
|
||||
|
||||
class FlvStreamCorruptedError(Exception):
|
||||
...
|
||||
|
||||
@@ -1,14 +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 FlvHeaderError, FlvDataError, FlvTagError
|
||||
from .models import (
|
||||
FlvTag,
|
||||
TagType,
|
||||
FlvHeader,
|
||||
FlvTagHeader,
|
||||
TAG_HEADER_SIZE,
|
||||
AUDIO_TAG_HEADER_SIZE,
|
||||
VIDEO_TAG_HEADER_SIZE,
|
||||
|
||||
AudioTag,
|
||||
AudioTagHeader,
|
||||
@@ -38,7 +43,8 @@ class FlvParser:
|
||||
|
||||
def parse_header(self) -> FlvHeader:
|
||||
signature = self._reader.read(3).decode()
|
||||
assert signature == 'FLV', 'Not a FLV file'
|
||||
if signature != 'FLV':
|
||||
raise FlvHeaderError(signature)
|
||||
version = self._reader.read_ui8()
|
||||
type_flag = self._reader.read_ui8()
|
||||
data_offset = self._reader.read_ui32()
|
||||
@@ -49,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 ValueError(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)
|
||||
assert not filtered, 'Unsupported Filtered FLV Tag'
|
||||
if filtered:
|
||||
raise FlvDataError('Unsupported Filtered FLV Tag', data)
|
||||
tag_type = TagType(flag & 0b0001_1111)
|
||||
data_size = self._reader.read_ui24()
|
||||
assert data_size > 0, 'Invalid Tag'
|
||||
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()
|
||||
stream_id = reader.read_ui24()
|
||||
return FlvTagHeader(
|
||||
filtered, tag_type, data_size, timestamp, stream_id
|
||||
)
|
||||
|
||||
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 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)
|
||||
|
||||
if sound_format != SoundFormat.AAC:
|
||||
aac_packet_type = None
|
||||
else:
|
||||
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:
|
||||
avc_packet_type = None
|
||||
composition_time = None
|
||||
else:
|
||||
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
|
||||
)
|
||||
@@ -149,7 +178,7 @@ class FlvDumper:
|
||||
elif tag.is_script_tag():
|
||||
pass
|
||||
else:
|
||||
raise ValueError(tag.tag_type)
|
||||
raise FlvDataError(f'Unsupported tag type: {tag.tag_type}')
|
||||
|
||||
if tag.body is None:
|
||||
self._stream.seek(tag.tag_end_offset)
|
||||
@@ -164,22 +193,23 @@ class FlvDumper:
|
||||
self._writer.write_ui24(tag.stream_id)
|
||||
|
||||
def dump_audio_tag_header(self, tag: AudioTag) -> None:
|
||||
if tag.sound_format != SoundFormat.AAC:
|
||||
raise FlvDataError(
|
||||
f'Unsupported sound format: {tag.sound_format}', tag
|
||||
)
|
||||
self._writer.write_ui8(
|
||||
(tag.sound_format.value << 4) |
|
||||
(tag.sound_rate.value << 2) |
|
||||
(tag.sound_size.value << 1) |
|
||||
tag.sound_type.value
|
||||
)
|
||||
if tag.aac_packet_type is not None:
|
||||
assert tag.sound_format == SoundFormat.AAC
|
||||
self._writer.write_ui8(tag.aac_packet_type)
|
||||
self._writer.write_ui8(tag.aac_packet_type)
|
||||
|
||||
def dump_video_tag_header(self, tag: VideoTag) -> None:
|
||||
if tag.codec_id != CodecID.AVC:
|
||||
raise FlvDataError(f'Unsupported video codec: {tag.codec_id}', tag)
|
||||
self._writer.write_ui8(
|
||||
(tag.frame_type.value << 4) | tag.codec_id.value
|
||||
)
|
||||
if tag.codec_id == CodecID.AVC:
|
||||
assert tag.avc_packet_type is not None
|
||||
assert tag.composition_time is not None
|
||||
self._writer.write_ui8(tag.avc_packet_type.value)
|
||||
self._writer.write_ui24(tag.composition_time)
|
||||
self._writer.write_ui8(tag.avc_packet_type.value)
|
||||
self._writer.write_ui24(tag.composition_time)
|
||||
|
||||
@@ -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')
|
||||
@@ -207,7 +209,7 @@ class AudioTag(FlvTag):
|
||||
sound_rate: SoundRate
|
||||
sound_size: SoundSize
|
||||
sound_type: SoundType
|
||||
aac_packet_type: Optional[AACPacketType]
|
||||
aac_packet_type: AACPacketType
|
||||
|
||||
@property
|
||||
def header_size(self) -> int:
|
||||
@@ -230,8 +232,8 @@ class AudioTag(FlvTag):
|
||||
class VideoTag(FlvTag):
|
||||
frame_type: FrameType
|
||||
codec_id: CodecID
|
||||
avc_packet_type: Optional[AVCPacketType]
|
||||
composition_time: Optional[int]
|
||||
avc_packet_type: AVCPacketType
|
||||
composition_time: int
|
||||
|
||||
@property
|
||||
def header_size(self) -> int:
|
||||
|
||||
@@ -14,7 +14,7 @@ from rx.subject import Subject
|
||||
from rx.core import Observable
|
||||
|
||||
from .models import FlvHeader, FlvTag, ScriptTag, VideoTag, AudioTag
|
||||
from .data_analyser import DataAnalyser
|
||||
from .data_analyser import DataAnalyser, MetaData
|
||||
from .stream_cutter import StreamCutter
|
||||
from .limit_checker import LimitChecker
|
||||
from .parameters_checker import ParametersChecker
|
||||
@@ -22,6 +22,7 @@ from .io import FlvReader, FlvWriter
|
||||
from .io_protocols import RandomIO
|
||||
from .utils import format_offest, format_timestamp
|
||||
from .exceptions import (
|
||||
FlvTagError,
|
||||
FlvStreamCorruptedError,
|
||||
AudioParametersChanged,
|
||||
VideoParametersChanged,
|
||||
@@ -30,9 +31,9 @@ from .exceptions import (
|
||||
CutStream,
|
||||
)
|
||||
from .common import (
|
||||
is_audio_tag, is_metadata_tag, is_video_tag, parse_metadata, rpeek_tags,
|
||||
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
|
||||
|
||||
@@ -70,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
|
||||
@@ -84,10 +86,9 @@ class StreamProcessor:
|
||||
|
||||
self._delta: int = 0
|
||||
self._has_audio: bool = False
|
||||
self._metadata_tag: ScriptTag
|
||||
self._last_ts: int = 0
|
||||
self._last_tags: List[FlvTag] = []
|
||||
self._join_points: List[JoinPoint] = []
|
||||
self._resetting_file: bool = False
|
||||
|
||||
@property
|
||||
def filesize_limit(self) -> int:
|
||||
@@ -116,6 +117,16 @@ class StreamProcessor:
|
||||
for point in self._join_points:
|
||||
yield point
|
||||
|
||||
@property
|
||||
def metadata(self) -> Optional[MetaData]:
|
||||
if not self._analyse_data:
|
||||
return None
|
||||
try:
|
||||
return self._data_analyser.make_metadata()
|
||||
except Exception as e:
|
||||
logger.debug(f'Failed to make metadata data, due to: {repr(e)}')
|
||||
return None
|
||||
|
||||
@property
|
||||
def size_updates(self) -> Observable:
|
||||
return self._size_updates
|
||||
@@ -162,16 +173,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,25 +206,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._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...')
|
||||
@@ -211,7 +217,7 @@ class StreamProcessor:
|
||||
|
||||
try:
|
||||
first_data_tag = self._read_first_data_tag()
|
||||
if self._stream_count == 1:
|
||||
if not self._last_tags:
|
||||
self._process_initial_stream(flv_header, first_data_tag)
|
||||
else:
|
||||
self._process_subsequent_stream(first_data_tag)
|
||||
@@ -226,16 +232,22 @@ 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
|
||||
del first_data_tag
|
||||
else:
|
||||
del flv_header, first_data_tag
|
||||
|
||||
self._transfer_tags_until_complete()
|
||||
|
||||
def _process_subsequent_stream(self, first_data_tag: FlvTag) -> None:
|
||||
@@ -333,7 +345,6 @@ class StreamProcessor:
|
||||
)
|
||||
|
||||
logger.debug('Meta tags have been transfered')
|
||||
self._update_last_out_tags()
|
||||
|
||||
def _transfer_first_data_tag(self, tag: FlvTag) -> None:
|
||||
logger.debug(f'Transfer the first data tag: {tag}')
|
||||
@@ -392,9 +403,6 @@ class StreamProcessor:
|
||||
finally:
|
||||
logger.debug(f'{count} tags have been transfered')
|
||||
|
||||
if count > 0:
|
||||
self._update_last_out_tags()
|
||||
|
||||
def _add_join_point(
|
||||
self, offset: int, timestamp: int, seamless: bool
|
||||
) -> None:
|
||||
@@ -402,11 +410,6 @@ class StreamProcessor:
|
||||
self._join_points.append(join_point)
|
||||
logger.debug(f'{repr(join_point)}; {join_point}')
|
||||
|
||||
def _update_last_out_tags(self) -> None:
|
||||
self._last_tags = list(rpeek_tags(
|
||||
self._out_file, self._out_reader, self._TAG_SEQUENCE_COUNT
|
||||
))
|
||||
|
||||
def _find_last_duplicated_tag(self, tags: List[FlvTag]) -> int:
|
||||
logger.debug('Finding duplicated tags...')
|
||||
|
||||
@@ -465,17 +468,18 @@ class StreamProcessor:
|
||||
self._size_updates.on_next(size)
|
||||
|
||||
def _write_tag(self, tag: FlvTag) -> None:
|
||||
if self._analyse_data:
|
||||
offset = self._out_file.tell()
|
||||
tag = tag.evolve(offset=offset)
|
||||
offset = self._out_file.tell()
|
||||
tag = tag.evolve(offset=offset)
|
||||
|
||||
try:
|
||||
size = self._out_writer.write_tag(tag)
|
||||
except Exception as exc:
|
||||
logger.debug(f'Failed to write data, due to: {repr(exc)}')
|
||||
raise
|
||||
|
||||
self._last_ts = tag.timestamp
|
||||
else:
|
||||
self._last_tags.insert(0, tag)
|
||||
if len(self._last_tags) > self._TAG_SEQUENCE_COUNT:
|
||||
self._last_tags.pop()
|
||||
|
||||
self._stream_cutter.check_tag(tag)
|
||||
if not self._disable_limit:
|
||||
@@ -496,6 +500,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(
|
||||
@@ -503,9 +509,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
|
||||
@@ -688,7 +694,24 @@ class BaseOutputFileManager(ABC):
|
||||
...
|
||||
|
||||
|
||||
class FlvReaderWithTimestampFix(FlvReader):
|
||||
class RobustFlvReader(FlvReader):
|
||||
def read_tag(self, *, no_body: bool = False) -> FlvTag:
|
||||
count = 0
|
||||
while True:
|
||||
try:
|
||||
tag = super().read_tag(no_body=no_body)
|
||||
except FlvTagError as e:
|
||||
logger.warning(f'Invalid tag: {repr(e)}')
|
||||
self._parser.parse_previous_tag_size()
|
||||
count += 1
|
||||
if count > 3:
|
||||
raise
|
||||
else:
|
||||
count = 0
|
||||
return tag
|
||||
|
||||
|
||||
class FlvReaderWithTimestampFix(RobustFlvReader):
|
||||
def __init__(self, stream: RandomIO) -> None:
|
||||
super().__init__(stream)
|
||||
self._last_tag: Optional[FlvTag] = None
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .configure import configure_logger, TqdmOutputStream, ConsoleHandler
|
||||
from .configure_logging import configure_logger, TqdmOutputStream, ConsoleHandler
|
||||
|
||||
|
||||
__all__ = (
|
||||
|
||||
@@ -1,133 +1,135 @@
|
||||
import os
|
||||
import logging
|
||||
from logging import LogRecord, Handler
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
import threading
|
||||
import atexit
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from colorama import init, deinit, Fore, Back, Style
|
||||
from tqdm import tqdm
|
||||
|
||||
from .typing import LOG_LEVEL
|
||||
|
||||
|
||||
__all__ = 'configure_logger', 'ConsoleHandler', 'TqdmOutputStream'
|
||||
|
||||
|
||||
class TqdmOutputStream:
|
||||
def write(self, string: str = '') -> None:
|
||||
tqdm.write(string, end='')
|
||||
|
||||
|
||||
class ConsoleHandler(logging.StreamHandler):
|
||||
def __init__(self, stream=None) -> None: # type: ignore
|
||||
super().__init__(stream)
|
||||
|
||||
def format(self, record: LogRecord) -> str:
|
||||
msg = super().format(record)
|
||||
|
||||
level = record.levelno
|
||||
if level == logging.DEBUG:
|
||||
style = Fore.GREEN
|
||||
elif level == logging.WARNING:
|
||||
style = Fore.YELLOW
|
||||
elif level == logging.ERROR:
|
||||
style = Fore.RED
|
||||
elif level == logging.CRITICAL:
|
||||
style = Fore.WHITE + Back.RED + Style.BRIGHT
|
||||
else:
|
||||
style = ''
|
||||
|
||||
return style + msg + Style.RESET_ALL if style else msg
|
||||
|
||||
|
||||
_old_factory = logging.getLogRecordFactory()
|
||||
|
||||
|
||||
def obtain_room_id() -> str:
|
||||
try:
|
||||
task = asyncio.current_task()
|
||||
assert task is not None
|
||||
except Exception:
|
||||
name = threading.current_thread().getName()
|
||||
else:
|
||||
name = task.get_name()
|
||||
|
||||
if '::' in name:
|
||||
if (room_id := name.split('::')[-1]):
|
||||
return room_id
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
def record_factory(*args: Any, **kwargs: Any) -> LogRecord:
|
||||
record = _old_factory(*args, **kwargs)
|
||||
|
||||
if (room_id := obtain_room_id()):
|
||||
record.roomid = '[' + room_id + '] ' # type: ignore
|
||||
else:
|
||||
record.roomid = '' # type: ignore
|
||||
|
||||
return record
|
||||
|
||||
|
||||
logging.setLogRecordFactory(record_factory)
|
||||
|
||||
|
||||
_old_handlers: List[Handler] = []
|
||||
|
||||
|
||||
def configure_logger(
|
||||
out_dir: str,
|
||||
*,
|
||||
console_log_level: LOG_LEVEL = 'INFO',
|
||||
max_bytes: Optional[int] = None,
|
||||
backup_count: Optional[int] = None,
|
||||
) -> None:
|
||||
# config root logger
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# config formatter
|
||||
formatter = logging.Formatter(
|
||||
'[%(asctime)s] [%(levelname)s] [%(module)s] %(roomid)s%(message)s'
|
||||
)
|
||||
|
||||
# logging to console
|
||||
console_handler = ConsoleHandler(TqdmOutputStream())
|
||||
console_handler.setLevel(logging.getLevelName(console_log_level))
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# logging to file
|
||||
file_handler = RotatingFileHandler(
|
||||
make_log_file_path(out_dir),
|
||||
maxBytes=max_bytes or 1024 ** 2 * 10,
|
||||
backupCount=backup_count or 1,
|
||||
encoding='utf-8',
|
||||
)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# remove old handlers after re-configured
|
||||
for handler in _old_handlers:
|
||||
logger.removeHandler(handler)
|
||||
|
||||
# retain old handlers for the removing
|
||||
_old_handlers.append(console_handler)
|
||||
_old_handlers.append(file_handler)
|
||||
|
||||
|
||||
def make_log_file_path(out_dir: str) -> str:
|
||||
data_time_string = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
filename = f'blrec_{data_time_string}.log'
|
||||
path = os.path.join(out_dir, filename)
|
||||
return path
|
||||
|
||||
|
||||
init()
|
||||
atexit.register(deinit)
|
||||
import os
|
||||
import logging
|
||||
from logging import LogRecord, Handler
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
import threading
|
||||
import atexit
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from colorama import init, deinit, Fore, Back, Style
|
||||
from tqdm import tqdm
|
||||
|
||||
from .typing import LOG_LEVEL
|
||||
|
||||
|
||||
__all__ = 'configure_logger', 'ConsoleHandler', 'TqdmOutputStream'
|
||||
|
||||
|
||||
class TqdmOutputStream:
|
||||
def write(self, string: str = '') -> None:
|
||||
tqdm.write(string, end='')
|
||||
|
||||
|
||||
class ConsoleHandler(logging.StreamHandler):
|
||||
def __init__(self, stream=None) -> None: # type: ignore
|
||||
super().__init__(stream)
|
||||
|
||||
def format(self, record: LogRecord) -> str:
|
||||
msg = super().format(record)
|
||||
|
||||
level = record.levelno
|
||||
if level == logging.DEBUG:
|
||||
style = Fore.GREEN
|
||||
elif level == logging.WARNING:
|
||||
style = Fore.YELLOW
|
||||
elif level == logging.ERROR:
|
||||
style = Fore.RED
|
||||
elif level == logging.CRITICAL:
|
||||
style = Fore.WHITE + Back.RED + Style.BRIGHT
|
||||
else:
|
||||
style = ''
|
||||
|
||||
return style + msg + Style.RESET_ALL if style else msg
|
||||
|
||||
|
||||
_old_factory = logging.getLogRecordFactory()
|
||||
|
||||
|
||||
def obtain_room_id() -> str:
|
||||
try:
|
||||
task = asyncio.current_task()
|
||||
assert task is not None
|
||||
except Exception:
|
||||
name = threading.current_thread().getName()
|
||||
else:
|
||||
name = task.get_name()
|
||||
|
||||
if '::' in name:
|
||||
if (room_id := name.split('::')[-1]):
|
||||
return room_id
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
def record_factory(*args: Any, **kwargs: Any) -> LogRecord:
|
||||
record = _old_factory(*args, **kwargs)
|
||||
|
||||
if (room_id := obtain_room_id()):
|
||||
record.roomid = '[' + room_id + '] ' # type: ignore
|
||||
else:
|
||||
record.roomid = '' # type: ignore
|
||||
|
||||
return record
|
||||
|
||||
|
||||
logging.setLogRecordFactory(record_factory)
|
||||
|
||||
|
||||
_old_handlers: List[Handler] = []
|
||||
|
||||
|
||||
def configure_logger(
|
||||
log_dir: str,
|
||||
*,
|
||||
console_log_level: LOG_LEVEL = 'INFO',
|
||||
max_bytes: Optional[int] = None,
|
||||
backup_count: Optional[int] = None,
|
||||
) -> None:
|
||||
# config root logger
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# config formatter
|
||||
formatter = logging.Formatter(
|
||||
'[%(asctime)s] [%(levelname)s] [%(module)s] %(roomid)s%(message)s'
|
||||
)
|
||||
|
||||
# logging to console
|
||||
console_handler = ConsoleHandler(TqdmOutputStream())
|
||||
console_handler.setLevel(logging.getLevelName(console_log_level))
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# logging to file
|
||||
log_file_path = make_log_file_path(log_dir)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file_path,
|
||||
maxBytes=max_bytes or 1024 ** 2 * 10,
|
||||
backupCount=backup_count or 1,
|
||||
encoding='utf-8',
|
||||
)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# remove old handlers after re-configured
|
||||
for handler in _old_handlers:
|
||||
logger.removeHandler(handler)
|
||||
|
||||
# retain old handlers for the removing
|
||||
_old_handlers.append(console_handler)
|
||||
_old_handlers.append(file_handler)
|
||||
|
||||
logger.info(f'log file: {log_file_path}')
|
||||
|
||||
|
||||
def make_log_file_path(log_dir: str) -> str:
|
||||
data_time_string = datetime.now().strftime("%Y-%m-%d-%H%M%S-%f")
|
||||
filename = f'blrec_{data_time_string}.log'
|
||||
return os.path.abspath(os.path.join(log_dir, filename))
|
||||
|
||||
|
||||
init()
|
||||
atexit.register(deinit)
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from .models import (
|
||||
DEFAULT_SETTINGS_PATH,
|
||||
DEFAULT_SETTINGS_FILE,
|
||||
|
||||
EnvSettings,
|
||||
Settings,
|
||||
@@ -36,7 +36,7 @@ from .setting_manager import SettingsManager
|
||||
|
||||
|
||||
__all__ = (
|
||||
'DEFAULT_SETTINGS_PATH',
|
||||
'DEFAULT_SETTINGS_FILE',
|
||||
|
||||
'EnvSettings',
|
||||
'Settings',
|
||||
|
||||
@@ -15,23 +15,20 @@ from typing import (
|
||||
|
||||
import toml
|
||||
from pydantic import BaseModel as PydanticBaseModel
|
||||
from pydantic import Field, BaseSettings, validator, PrivateAttr, DirectoryPath
|
||||
from pydantic import Field, BaseSettings, validator, PrivateAttr
|
||||
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__)
|
||||
|
||||
|
||||
__all__ = (
|
||||
'DEFAULT_SETTINGS_PATH',
|
||||
'DEFAULT_SETTINGS_FILE',
|
||||
|
||||
'EnvSettings',
|
||||
'Settings',
|
||||
@@ -64,26 +61,19 @@ __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
|
||||
DEFAULT_OUT_DIR: Final[str] = os.environ.get('DEFAULT_OUT_DIR', '.')
|
||||
DEFAULT_LOG_DIR: Final[str] = os.environ.get(
|
||||
'DEFAULT_LOG_DIR', '~/.blrec/logs/'
|
||||
)
|
||||
DEFAULT_SETTINGS_FILE: Final[str] = os.environ.get(
|
||||
'DEFAULT_SETTINGS_FILE', '~/.blrec/settings.toml'
|
||||
)
|
||||
|
||||
|
||||
class EnvSettings(BaseSettings):
|
||||
settings_file: Annotated[
|
||||
str, Field(env='config', default_factory=settings_file_factory)
|
||||
]
|
||||
settings_file: Annotated[str, Field(env='config')] = DEFAULT_SETTINGS_FILE
|
||||
out_dir: Optional[str] = None
|
||||
log_dir: Optional[str] = None
|
||||
api_key: Annotated[
|
||||
Optional[str],
|
||||
Field(min_length=8, max_length=80, regex=r'[a-zA-Z\d\-]{8,80}'),
|
||||
@@ -98,7 +88,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 +122,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 +131,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
|
||||
@@ -248,8 +239,14 @@ class OutputOptions(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
def out_dir_factory() -> str:
|
||||
path = os.path.expanduser(DEFAULT_OUT_DIR)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
class OutputSettings(OutputOptions):
|
||||
out_dir: Annotated[str, DirectoryPath] = '.'
|
||||
out_dir: Annotated[str, Field(default_factory=out_dir_factory)]
|
||||
path_template: str = (
|
||||
'{roomid} - {uname}/'
|
||||
'blive_{roomid}_{year}-{month}-{day}-{hour}{minute}{second}'
|
||||
@@ -287,13 +284,26 @@ class TaskSettings(TaskOptions):
|
||||
enable_recorder: bool = True
|
||||
|
||||
|
||||
def log_dir_factory() -> str:
|
||||
path = os.path.expanduser(DEFAULT_LOG_DIR)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
class LoggingSettings(BaseModel):
|
||||
log_dir: Annotated[str, Field(default_factory=log_dir_factory)]
|
||||
console_log_level: LOG_LEVEL = 'INFO'
|
||||
max_bytes: Annotated[
|
||||
int, Field(ge=1024 ** 2, le=1024 ** 2 * 10, multiple_of=1024 ** 2)
|
||||
] = 1024 ** 2 * 10 # allowed 1 ~ 10 MB
|
||||
backup_count: Annotated[int, Field(ge=1, le=30)] = 30
|
||||
|
||||
@validator('log_dir')
|
||||
def _validate_dir(cls, path: str) -> str:
|
||||
if not os.path.isdir(os.path.expanduser(path)):
|
||||
raise ValueError(f"'{path}' not a directory")
|
||||
return path
|
||||
|
||||
|
||||
class SpaceSettings(BaseModel):
|
||||
check_interval: int = 60 # 1 minutes
|
||||
@@ -302,13 +312,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 +385,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
|
||||
|
||||
|
||||
@@ -415,6 +434,8 @@ class Settings(BaseModel):
|
||||
def update_from_env_settings(self, env_settings: EnvSettings) -> None:
|
||||
if (out_dir := env_settings.out_dir) is not None:
|
||||
self.output.out_dir = out_dir
|
||||
if (log_dir := env_settings.log_dir) is not None:
|
||||
self.logging.log_dir = log_dir
|
||||
|
||||
def dump(self) -> None:
|
||||
assert self._path
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import asyncio
|
||||
from typing import Optional, TYPE_CHECKING, cast
|
||||
|
||||
@@ -249,18 +248,13 @@ class SettingsManager:
|
||||
self.apply_task_output_settings(settings.room_id, settings.output)
|
||||
|
||||
out_dir = self._settings.output.out_dir
|
||||
self._app._out_dir = out_dir
|
||||
self._app._space_monitor.path = out_dir
|
||||
self._app._space_reclaimer.path = out_dir
|
||||
|
||||
prev_out_dir = self._app._out_dir
|
||||
self._app._out_dir = out_dir
|
||||
|
||||
if not os.path.samefile(out_dir, prev_out_dir):
|
||||
self.apply_logging_settings()
|
||||
|
||||
def apply_logging_settings(self) -> None:
|
||||
configure_logger(
|
||||
self._app._out_dir,
|
||||
log_dir=self._settings.logging.log_dir,
|
||||
console_log_level=self._settings.logging.console_log_level,
|
||||
max_bytes=self._settings.logging.max_bytes,
|
||||
backup_count=self._settings.logging.backup_count,
|
||||
|
||||
@@ -29,6 +29,7 @@ class TaskStatus:
|
||||
danmu_count: int # Number of Danmu in total
|
||||
danmu_rate: float # Number of Danmu per minutes
|
||||
real_quality_number: QualityNumber
|
||||
recording_path: Optional[str] = None
|
||||
postprocessor_status: PostprocessorStatus = PostprocessorStatus.WAITING
|
||||
postprocessing_path: Optional[str] = None
|
||||
postprocessing_progress: Optional[Progress] = None
|
||||
@@ -47,6 +48,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
|
||||
|
||||
@@ -21,8 +21,9 @@ from ..core import Recorder
|
||||
from ..postprocess import Postprocessor, PostprocessorStatus, DeleteStrategy
|
||||
from ..postprocess.remuxer import RemuxProgress
|
||||
from ..flv.metadata_injector import InjectProgress
|
||||
from ..flv.data_analyser import MetaData
|
||||
from ..event.event_submitters import (
|
||||
LiveEventSubmitter, PostprocessorEventSubmitter
|
||||
LiveEventSubmitter, RecorderEventSubmitter, PostprocessorEventSubmitter
|
||||
)
|
||||
from ..logging.room_id import aio_task_with_room_id
|
||||
|
||||
@@ -44,6 +45,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 +70,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
|
||||
@@ -130,6 +133,7 @@ class RecordTask:
|
||||
danmu_count=self._recorder.danmu_count,
|
||||
danmu_rate=self._recorder.danmu_rate,
|
||||
real_quality_number=self._recorder.real_quality_number,
|
||||
recording_path=self.recording_path,
|
||||
postprocessor_status=self._postprocessor.status,
|
||||
postprocessing_path=self._postprocessor.postprocessing_path,
|
||||
postprocessing_progress=(
|
||||
@@ -239,6 +243,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
|
||||
@@ -339,6 +351,14 @@ class RecordTask:
|
||||
def duration_limit(self, value: int) -> None:
|
||||
self._recorder.duration_limit = value
|
||||
|
||||
@property
|
||||
def recording_path(self) -> Optional[str]:
|
||||
return self._recorder.recording_path
|
||||
|
||||
@property
|
||||
def metadata(self) -> Optional[MetaData]:
|
||||
return self._recorder.metadata
|
||||
|
||||
@property
|
||||
def remux_to_mp4(self) -> bool:
|
||||
return self._postprocessor.remux_to_mp4
|
||||
@@ -442,6 +462,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 +489,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 +498,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 +510,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 +535,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
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
from typing import Dict, Iterator, TYPE_CHECKING
|
||||
from typing import Dict, Iterator, Optional, TYPE_CHECKING
|
||||
|
||||
|
||||
from .task import RecordTask
|
||||
from .models import TaskData, TaskParam, VideoFileDetail, DanmakuFileDetail
|
||||
from ..flv.data_analyser import MetaData
|
||||
from ..exception import NotFoundError
|
||||
if TYPE_CHECKING:
|
||||
from ..setting import SettingsManager
|
||||
@@ -154,6 +155,10 @@ class RecordTaskManager:
|
||||
task = self._get_task(room_id)
|
||||
return self._make_task_param(task)
|
||||
|
||||
def get_task_metadata(self, room_id: int) -> Optional[MetaData]:
|
||||
task = self._get_task(room_id)
|
||||
return task.metadata
|
||||
|
||||
def get_task_video_file_details(
|
||||
self, room_id: int
|
||||
) -> Iterator[VideoFileDetail]:
|
||||
@@ -221,6 +226,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 +265,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,
|
||||
|
||||
6
src/blrec/utils/url.py
Normal file
6
src/blrec/utils/url.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
from typing import Literal
|
||||
|
||||
|
||||
def ensure_scheme(url: str, scheme: Literal['http', 'https']) -> str:
|
||||
return urlunparse(urlparse(url)._replace(scheme=scheme))
|
||||
@@ -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',
|
||||
|
||||
@@ -53,6 +53,17 @@ async def get_task_param(room_id: int) -> Dict[str, Any]:
|
||||
return attr.asdict(app.get_task_param(room_id))
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{room_id}/metadata',
|
||||
responses={**not_found_responses},
|
||||
)
|
||||
async def get_task_metadata(room_id: int) -> Dict[str, Any]:
|
||||
metadata = app.get_task_metadata(room_id)
|
||||
if not metadata:
|
||||
return {}
|
||||
return attr.asdict(metadata)
|
||||
|
||||
|
||||
@router.get(
|
||||
'/{room_id}/videos',
|
||||
responses={**not_found_responses},
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import logging
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from websockets import ConnectionClosed # type: ignore
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
from ...event import EventCenter
|
||||
from ...event.typing import Event
|
||||
@@ -33,7 +34,8 @@ async def receive_events(websocket: WebSocket) -> None:
|
||||
|
||||
async def send_event(event: Event) -> None:
|
||||
try:
|
||||
await websocket.send_json(event.asdict())
|
||||
text = json.dumps(event.asdict(), ensure_ascii=False)
|
||||
await websocket.send_text(text)
|
||||
except (WebSocketDisconnect, ConnectionClosed) as e:
|
||||
logger.debug(f'Events websocket closed: {repr(e)}')
|
||||
subscription.dispose()
|
||||
|
||||
81
src/blrec/web/security.py
Normal file
81
src/blrec/web/security.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Optional, Set, Dict
|
||||
|
||||
from fastapi import status, Request, Header
|
||||
from fastapi.exceptions import HTTPException
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
api_key = ''
|
||||
|
||||
MAX_WHITELIST = 100
|
||||
MAX_BLACKLIST = 100
|
||||
MAX_ATTEMPTING_CLIENTS = 100
|
||||
MAX_ATTEMPTS = 3
|
||||
whitelist: Set[str] = set()
|
||||
blacklist: Set[str] = set()
|
||||
attempting_clients: Dict[str, int] = {}
|
||||
|
||||
|
||||
async def authenticate(
|
||||
request: Request,
|
||||
x_api_key: Optional[str] = Header(None),
|
||||
) -> None:
|
||||
assert api_key, 'api_key is required'
|
||||
|
||||
if not x_api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='No api key',
|
||||
)
|
||||
|
||||
client_ip = request.client.host
|
||||
assert client_ip, 'client_ip is required'
|
||||
|
||||
if client_ip in blacklist:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Blacklisted',
|
||||
)
|
||||
if client_ip not in whitelist:
|
||||
if (
|
||||
len(whitelist) >= MAX_WHITELIST or
|
||||
len(blacklist) >= MAX_BLACKLIST
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Max clients allowed in whitelist or blacklist '
|
||||
'will exceeded',
|
||||
)
|
||||
if len(attempting_clients) >= MAX_ATTEMPTING_CLIENTS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Max attempting clients allowed exceeded',
|
||||
)
|
||||
if client_ip not in attempting_clients:
|
||||
attempting_clients[client_ip] = 1
|
||||
else:
|
||||
attempting_clients[client_ip] += 1
|
||||
if attempting_clients[client_ip] > MAX_ATTEMPTS:
|
||||
del attempting_clients[client_ip]
|
||||
blacklist.add(client_ip)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Max api key attempts exceeded',
|
||||
)
|
||||
|
||||
if not secrets.compare_digest(x_api_key, api_key):
|
||||
if client_ip in whitelist:
|
||||
whitelist.remove(client_ip)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='API key is invalid',
|
||||
)
|
||||
|
||||
if client_ip in attempting_clients:
|
||||
del attempting_clients[client_ip]
|
||||
if client_ip not in whitelist:
|
||||
whitelist.add(client_ip)
|
||||
@@ -9,8 +9,17 @@ from ..event import (
|
||||
LiveBeganEvent,
|
||||
LiveEndedEvent,
|
||||
RoomChangeEvent,
|
||||
RecordingStartedEvent,
|
||||
RecordingFinishedEvent,
|
||||
RecordingCancelledEvent,
|
||||
VideoFileCreatedEvent,
|
||||
VideoFileCompletedEvent,
|
||||
DanmakuFileCreatedEvent,
|
||||
DanmakuFileCompletedEvent,
|
||||
RawDanmakuFileCreatedEvent,
|
||||
RawDanmakuFileCompletedEvent,
|
||||
SpaceNoEnoughEvent,
|
||||
FileCompletedEvent,
|
||||
VideoPostprocessingCompletedEvent,
|
||||
)
|
||||
from ..event.typing import Event
|
||||
|
||||
@@ -37,8 +46,26 @@ class WebHook:
|
||||
types.add(LiveEndedEvent)
|
||||
if settings.room_change:
|
||||
types.add(RoomChangeEvent)
|
||||
if settings.file_completed:
|
||||
types.add(FileCompletedEvent)
|
||||
if settings.recording_started:
|
||||
types.add(RecordingStartedEvent)
|
||||
if settings.recording_finished:
|
||||
types.add(RecordingFinishedEvent)
|
||||
if settings.recording_cancelled:
|
||||
types.add(RecordingCancelledEvent)
|
||||
if settings.video_file_created:
|
||||
types.add(VideoFileCreatedEvent)
|
||||
if settings.video_file_completed:
|
||||
types.add(VideoFileCompletedEvent)
|
||||
if settings.danmaku_file_created:
|
||||
types.add(DanmakuFileCreatedEvent)
|
||||
if settings.danmaku_file_completed:
|
||||
types.add(DanmakuFileCompletedEvent)
|
||||
if settings.raw_danmaku_file_created:
|
||||
types.add(RawDanmakuFileCreatedEvent)
|
||||
if settings.raw_danmaku_file_completed:
|
||||
types.add(RawDanmakuFileCompletedEvent)
|
||||
if settings.video_postprocessing_completed:
|
||||
types.add(VideoPostprocessingCompletedEvent)
|
||||
if settings.space_no_enough:
|
||||
types.add(SpaceNoEnoughEvent)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from tenacity import (
|
||||
|
||||
from .models import WebHook
|
||||
from ..utils.mixins import SwitchableMixin
|
||||
from ..exception import ExceptionCenter, format_exception
|
||||
from ..exception import ExceptionCenter
|
||||
from ..event import EventCenter, Error, ErrorData
|
||||
from ..event.typing import Event
|
||||
from .. import __prog__, __version__
|
||||
@@ -57,11 +57,7 @@ class WebHookEmitter(SwitchableMixin):
|
||||
self._send_request(url, event.asdict())
|
||||
|
||||
def _send_exception(self, url: str, exc: BaseException) -> None:
|
||||
data = ErrorData(
|
||||
name=type(exc).__name__,
|
||||
detail=format_exception(exc),
|
||||
)
|
||||
payload = Error.from_data(data).asdict()
|
||||
payload = Error.from_data(ErrorData.from_exc(exc)).asdict()
|
||||
self._send_request(url, payload)
|
||||
|
||||
def _send_request(self, url: str, payload: Dict[str, Any]) -> None:
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { catchError, retry } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class AuthInterceptor implements HttpInterceptor {
|
||||
@@ -26,11 +26,15 @@ export class AuthInterceptor implements HttpInterceptor {
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
if (error.status === 401) {
|
||||
// Unauthorized
|
||||
if (this.auth.hasApiKey()) {
|
||||
this.auth.removeApiKey();
|
||||
}
|
||||
const apiKey = window.prompt('API Key:') ?? '';
|
||||
this.auth.setApiKey(apiKey);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
}),
|
||||
retry(3)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,17 @@ export type Event =
|
||||
| LiveBeganEvent
|
||||
| LiveEndedEvent
|
||||
| RoomChangeEvent
|
||||
| RecordingStartedEvent
|
||||
| RecordingFinishedEvent
|
||||
| RecordingCancelledEvent
|
||||
| VideoFileCreatedEvent
|
||||
| VideoFileCompletedEvent
|
||||
| DanmakuFileCreatedEvent
|
||||
| DanmakuFileCompletedEvent
|
||||
| RawDanmakuFileCreatedEvent
|
||||
| RawDanmakuFileCompletedEvent
|
||||
| SpaceNoEnoughEvent
|
||||
| FilesAvailableEvent;
|
||||
| VideoPostprocessingCompletedEvent;
|
||||
|
||||
export interface LiveBeganEvent {
|
||||
readonly type: 'LiveBeganEvent';
|
||||
@@ -29,6 +38,75 @@ export interface RoomChangeEvent {
|
||||
readonly room_info: RoomInfo;
|
||||
};
|
||||
}
|
||||
export interface RecordingStartedEvent {
|
||||
readonly type: 'RecordingStartedEvent';
|
||||
readonly data: {
|
||||
readonly room_info: RoomInfo;
|
||||
};
|
||||
}
|
||||
export interface RecordingFinishedEvent {
|
||||
readonly type: 'RecordingFinishedEvent';
|
||||
readonly data: {
|
||||
readonly room_info: RoomInfo;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecordingCancelledEvent {
|
||||
readonly type: 'RecordingCancelledEvent';
|
||||
readonly data: {
|
||||
readonly room_info: RoomInfo;
|
||||
};
|
||||
}
|
||||
export interface VideoFileCreatedEvent {
|
||||
readonly type: 'VideoFileCreatedEvent';
|
||||
readonly data: {
|
||||
room_id: number;
|
||||
path: string;
|
||||
};
|
||||
}
|
||||
export interface VideoFileCompletedEvent {
|
||||
readonly type: 'VideoFileCompletedEvent';
|
||||
readonly data: {
|
||||
room_id: number;
|
||||
path: string;
|
||||
};
|
||||
}
|
||||
export interface DanmakuFileCreatedEvent {
|
||||
readonly type: 'DanmakuFileCreatedEvent';
|
||||
readonly data: {
|
||||
room_id: number;
|
||||
path: string;
|
||||
};
|
||||
}
|
||||
export interface DanmakuFileCompletedEvent {
|
||||
readonly type: 'DanmakuFileCompletedEvent';
|
||||
readonly data: {
|
||||
room_id: number;
|
||||
path: string;
|
||||
};
|
||||
}
|
||||
export interface RawDanmakuFileCreatedEvent {
|
||||
readonly type: 'RawDanmakuFileCreatedEvent';
|
||||
readonly data: {
|
||||
room_id: number;
|
||||
path: string;
|
||||
};
|
||||
}
|
||||
export interface RawDanmakuFileCompletedEvent {
|
||||
readonly type: 'RawDanmakuFileCompletedEvent';
|
||||
readonly data: {
|
||||
room_id: number;
|
||||
path: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VideoPostprocessingCompletedEvent {
|
||||
readonly type: 'VideoPostprocessingCompletedEvent';
|
||||
readonly data: {
|
||||
room_id: number;
|
||||
path: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SpaceNoEnoughEvent {
|
||||
readonly type: 'SpaceNoEnoughEvent';
|
||||
@@ -39,13 +117,6 @@ export interface SpaceNoEnoughEvent {
|
||||
};
|
||||
}
|
||||
|
||||
export interface FilesAvailableEvent {
|
||||
readonly type: 'FilesAvailableEvent';
|
||||
readonly data: {
|
||||
files: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DiskUsage {
|
||||
total: number;
|
||||
used: number;
|
||||
|
||||
@@ -9,6 +9,10 @@ const API_KEY_STORAGE_KEY = 'app-api-key';
|
||||
export class AuthService {
|
||||
constructor(private storage: StorageService) {}
|
||||
|
||||
hasApiKey(): boolean {
|
||||
return this.storage.hasData(API_KEY_STORAGE_KEY);
|
||||
}
|
||||
|
||||
getApiKey(): string {
|
||||
return this.storage.getData(API_KEY_STORAGE_KEY) ?? '';
|
||||
}
|
||||
@@ -16,4 +20,8 @@ export class AuthService {
|
||||
setApiKey(value: string): void {
|
||||
this.storage.setData(API_KEY_STORAGE_KEY, value);
|
||||
}
|
||||
|
||||
removeApiKey() {
|
||||
this.storage.removeData(API_KEY_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
nzTooltipTitle="记录送礼信息到弹幕文件里"
|
||||
>记录送礼</nz-form-label
|
||||
nzTooltipTitle="记录礼物信息到弹幕文件里"
|
||||
>记录礼物</nz-form-label
|
||||
>
|
||||
<nz-form-control
|
||||
class="setting-control switch"
|
||||
@@ -16,6 +16,23 @@
|
||||
<nz-switch formControlName="recordGiftSend"></nz-switch>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item" appSwitchActionable>
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
nzTooltipTitle="记录免费礼物信息到弹幕文件里"
|
||||
>记录免费礼物</nz-form-label
|
||||
>
|
||||
<nz-form-control
|
||||
class="setting-control switch"
|
||||
[nzWarningTip]="syncFailedWarningTip"
|
||||
[nzValidateStatus]="
|
||||
syncStatus.recordFreeGifts ? recordFreeGiftsControl : 'warning'
|
||||
"
|
||||
>
|
||||
<nz-switch formControlName="recordFreeGifts"></nz-switch>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item" appSwitchActionable>
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
|
||||
@@ -40,6 +40,7 @@ export class DanmakuSettingsComponent implements OnInit, OnChanges {
|
||||
this.settingsForm = formBuilder.group({
|
||||
danmuUname: [''],
|
||||
recordGiftSend: [''],
|
||||
recordFreeGifts: [''],
|
||||
recordGuardBuy: [''],
|
||||
recordSuperChat: [''],
|
||||
saveRawDanmaku: [''],
|
||||
@@ -54,6 +55,10 @@ export class DanmakuSettingsComponent implements OnInit, OnChanges {
|
||||
return this.settingsForm.get('recordGiftSend') as FormControl;
|
||||
}
|
||||
|
||||
get recordFreeGiftsControl() {
|
||||
return this.settingsForm.get('recordFreeGifts') as FormControl;
|
||||
}
|
||||
|
||||
get recordGuardBuyControl() {
|
||||
return this.settingsForm.get('recordGuardBuy') as FormControl;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ export class DiskSpaceSettingsComponent implements OnInit, OnChanges {
|
||||
readonly syncFailedWarningTip = SYNC_FAILED_WARNING_TIP;
|
||||
|
||||
readonly intervalOptions = [
|
||||
{ label: '10 秒', value: 10 },
|
||||
{ label: '30 秒', value: 30 },
|
||||
{ label: '1 分钟', value: 60 },
|
||||
{ label: '3 分钟', value: 180 },
|
||||
{ label: '5 分钟', value: 300 },
|
||||
@@ -44,6 +46,7 @@ export class DiskSpaceSettingsComponent implements OnInit, OnChanges {
|
||||
{ label: '3 GB', value: 1024 ** 3 * 3 },
|
||||
{ label: '5 GB', value: 1024 ** 3 * 5 },
|
||||
{ label: '10 GB', value: 1024 ** 3 * 10 },
|
||||
{ label: '20 GB', value: 1024 ** 3 * 20 },
|
||||
];
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<nz-modal
|
||||
nzTitle="修改日志文件存放目录"
|
||||
nzCentered
|
||||
[(nzVisible)]="visible"
|
||||
[nzOkDisabled]="control.invalid || control.value.trim() === value"
|
||||
(nzOnOk)="handleConfirm()"
|
||||
(nzOnCancel)="handleCancel()"
|
||||
>
|
||||
<ng-container *nzModalContent>
|
||||
<form nz-form [formGroup]="settingsForm">
|
||||
<nz-form-item>
|
||||
<nz-form-control
|
||||
nzHasFeedback
|
||||
nzValidatingTip="正在验证..."
|
||||
[nzErrorTip]="errorTip"
|
||||
>
|
||||
<input type="text" required nz-input formControlName="logDir" />
|
||||
<ng-template #errorTip let-control>
|
||||
<ng-container *ngIf="control.hasError('required')">
|
||||
请输入保存位置
|
||||
</ng-container>
|
||||
<ng-container *ngIf="control.hasError('notADirectory')">
|
||||
不是一个目录
|
||||
</ng-container>
|
||||
<ng-container *ngIf="control.hasError('noPermissions')">
|
||||
没有读写权限
|
||||
</ng-container>
|
||||
<ng-container *ngIf="control.hasError('failedToValidate')">
|
||||
未能进行验证
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</form>
|
||||
</ng-container>
|
||||
</nz-modal>
|
||||
@@ -0,0 +1 @@
|
||||
@use '../../shared/styles/setting';
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { LogdirEditDialogComponent } from './logdir-edit-dialog.component';
|
||||
|
||||
describe('LogdirEditDialogComponent', () => {
|
||||
let component: LogdirEditDialogComponent;
|
||||
let fixture: ComponentFixture<LogdirEditDialogComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ LogdirEditDialogComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(LogdirEditDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
Component,
|
||||
OnChanges,
|
||||
ChangeDetectionStrategy,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
ChangeDetectorRef,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ValidationErrors,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
|
||||
import { ERRNO } from 'src/app/core/models/validation.model';
|
||||
import { ValidationService } from 'src/app/core/services/validation.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-logdir-edit-dialog',
|
||||
templateUrl: './logdir-edit-dialog.component.html',
|
||||
styleUrls: ['./logdir-edit-dialog.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LogdirEditDialogComponent implements OnChanges {
|
||||
@Input() value = '';
|
||||
@Input() visible = false;
|
||||
@Output() visibleChange = new EventEmitter<boolean>();
|
||||
@Output() cancel = new EventEmitter<undefined>();
|
||||
@Output() confirm = new EventEmitter<string>();
|
||||
|
||||
readonly settingsForm: FormGroup;
|
||||
|
||||
constructor(
|
||||
formBuilder: FormBuilder,
|
||||
private changeDetector: ChangeDetectorRef,
|
||||
private validationService: ValidationService
|
||||
) {
|
||||
this.settingsForm = formBuilder.group({
|
||||
logDir: ['', [Validators.required], [this.logDirAsyncValidator]],
|
||||
});
|
||||
}
|
||||
|
||||
get control() {
|
||||
return this.settingsForm.get('logDir') as FormControl;
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.setValue();
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.setValue();
|
||||
this.setVisible(true);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.setVisible(false);
|
||||
}
|
||||
|
||||
setVisible(visible: boolean): void {
|
||||
this.visible = visible;
|
||||
this.visibleChange.emit(visible);
|
||||
this.changeDetector.markForCheck();
|
||||
}
|
||||
|
||||
setValue(): void {
|
||||
this.control.setValue(this.value);
|
||||
this.changeDetector.markForCheck();
|
||||
}
|
||||
|
||||
handleCancel(): void {
|
||||
this.cancel.emit();
|
||||
this.close();
|
||||
}
|
||||
|
||||
handleConfirm(): void {
|
||||
this.confirm.emit(this.control.value.trim());
|
||||
this.close();
|
||||
}
|
||||
|
||||
private logDirAsyncValidator = (
|
||||
control: FormControl
|
||||
): Observable<ValidationErrors | null> => {
|
||||
return this.validationService.validateDir(control.value).pipe(
|
||||
map((result) => {
|
||||
switch (result.code) {
|
||||
case ERRNO.ENOTDIR:
|
||||
return { error: true, notADirectory: true };
|
||||
case ERRNO.EACCES:
|
||||
return { error: true, noPermissions: true };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
catchError(() => of({ error: true, failedToValidate: true }))
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,23 @@
|
||||
<form nz-form [formGroup]="settingsForm">
|
||||
<nz-form-item
|
||||
class="setting-item actionable"
|
||||
(click)="logDirEditDialog.open()"
|
||||
>
|
||||
<nz-form-label class="setting-label">日志文件存放目录</nz-form-label>
|
||||
<nz-form-control
|
||||
[nzWarningTip]="syncFailedWarningTip"
|
||||
[nzValidateStatus]="syncStatus.logDir ? logDirControl : 'warning'"
|
||||
>
|
||||
<nz-form-text class="setting-value"
|
||||
>{{ logDirControl.value }}
|
||||
</nz-form-text>
|
||||
<app-logdir-edit-dialog
|
||||
#logDirEditDialog
|
||||
[value]="logDirControl.value"
|
||||
(confirm)="logDirControl.setValue($event)"
|
||||
></app-logdir-edit-dialog>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item" appSwitchActionable>
|
||||
<nz-form-label class="setting-label" nzNoColon
|
||||
>终端日志输出级别</nz-form-label
|
||||
|
||||
@@ -58,12 +58,17 @@ export class LoggingSettingsComponent implements OnInit, OnChanges {
|
||||
private settingsSyncService: SettingsSyncService
|
||||
) {
|
||||
this.settingsForm = formBuilder.group({
|
||||
logDir: [''],
|
||||
consoleLogLevel: [''],
|
||||
maxBytes: [''],
|
||||
backupCount: [''],
|
||||
});
|
||||
}
|
||||
|
||||
get logDirControl() {
|
||||
return this.settingsForm.get('logDir') as FormControl;
|
||||
}
|
||||
|
||||
get consoleLogLevelControl() {
|
||||
return this.settingsForm.get('consoleLogLevel') as FormControl;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<nz-modal
|
||||
nzTitle="修改文件保存位置"
|
||||
nzTitle="修改文件存放目录"
|
||||
nzCentered
|
||||
[(nzVisible)]="visible"
|
||||
[nzOkDisabled]="control.invalid || control.value.trim() === value"
|
||||
@@ -11,7 +11,7 @@
|
||||
<nz-form-item>
|
||||
<nz-form-control
|
||||
nzHasFeedback
|
||||
nzValidatingTip="正在校验..."
|
||||
nzValidatingTip="正在验验..."
|
||||
[nzErrorTip]="errorTip"
|
||||
>
|
||||
<input type="text" required nz-input formControlName="outDir" />
|
||||
@@ -26,7 +26,7 @@
|
||||
没有读写权限
|
||||
</ng-container>
|
||||
<ng-container *ngIf="control.hasError('failedToValidate')">
|
||||
未能进行校验
|
||||
未能进行验证
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</nz-form-control>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class="setting-item actionable"
|
||||
(click)="outDirEditDialog.open()"
|
||||
>
|
||||
<nz-form-label class="setting-label">保存位置</nz-form-label>
|
||||
<nz-form-label class="setting-label">存放目录</nz-form-label>
|
||||
<nz-form-control
|
||||
[nzWarningTip]="syncFailedWarningTip"
|
||||
[nzValidateStatus]="syncStatus.outDir ? outDirControl : 'warning'"
|
||||
|
||||
@@ -55,6 +55,7 @@ import { WebhookManagerComponent } from './webhook-settings/webhook-manager/webh
|
||||
import { WebhookEditDialogComponent } from './webhook-settings/webhook-edit-dialog/webhook-edit-dialog.component';
|
||||
import { WebhookListComponent } from './webhook-settings/webhook-list/webhook-list.component';
|
||||
import { OutdirEditDialogComponent } from './output-settings/outdir-edit-dialog/outdir-edit-dialog.component';
|
||||
import { LogdirEditDialogComponent } from './logging-settings/logdir-edit-dialog/logdir-edit-dialog.component';
|
||||
import { PathTemplateEditDialogComponent } from './output-settings/path-template-edit-dialog/path-template-edit-dialog.component';
|
||||
|
||||
@NgModule({
|
||||
@@ -84,6 +85,7 @@ import { PathTemplateEditDialogComponent } from './output-settings/path-template
|
||||
WebhookEditDialogComponent,
|
||||
WebhookListComponent,
|
||||
OutdirEditDialogComponent,
|
||||
LogdirEditDialogComponent,
|
||||
PathTemplateEditDialogComponent,
|
||||
],
|
||||
imports: [
|
||||
|
||||
@@ -10,6 +10,7 @@ export type HeaderOptions = Nullable<HeaderSettings>;
|
||||
export interface DanmakuSettings {
|
||||
danmuUname: boolean;
|
||||
recordGiftSend: boolean;
|
||||
recordFreeGifts: boolean;
|
||||
recordGuardBuy: boolean;
|
||||
recordSuperChat: boolean;
|
||||
saveRawDanmaku: boolean;
|
||||
@@ -88,6 +89,7 @@ export type LogLevel =
|
||||
| 'NOTSET';
|
||||
|
||||
export interface LoggingSettings {
|
||||
logDir: string;
|
||||
consoleLogLevel: LogLevel;
|
||||
maxBytes: number;
|
||||
backupCount: number;
|
||||
@@ -164,8 +166,17 @@ export interface WebhookEventSettings {
|
||||
liveBegan: boolean;
|
||||
liveEnded: boolean;
|
||||
roomChange: boolean;
|
||||
recordingStarted: boolean;
|
||||
recordingFinished: boolean;
|
||||
recordingCancelled: boolean;
|
||||
videoFileCreated: boolean;
|
||||
videoFileCompleted: boolean;
|
||||
danmakuFileCreated: boolean;
|
||||
danmakuFileCompleted: boolean;
|
||||
rawDanmakuFileCreated: boolean;
|
||||
rawDanmakuFileCompleted: boolean;
|
||||
videoPostprocessingCompleted: boolean;
|
||||
spaceNoEnough: boolean;
|
||||
fileCompleted: boolean;
|
||||
errorOccurred: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,8 +62,71 @@
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-control class="setting-control checkbox">
|
||||
<label nz-checkbox formControlName="fileCompleted"
|
||||
>录播文件完成</label
|
||||
<label nz-checkbox formControlName="recordingStarted"
|
||||
>录制开始</label
|
||||
>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-control class="setting-control checkbox">
|
||||
<label nz-checkbox formControlName="recordingFinished"
|
||||
>录制完成</label
|
||||
>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-control class="setting-control checkbox">
|
||||
<label nz-checkbox formControlName="recordingCancelled"
|
||||
>录制取消</label
|
||||
>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-control class="setting-control checkbox">
|
||||
<label nz-checkbox formControlName="videoFileCreated"
|
||||
>视频文件创建</label
|
||||
>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-control class="setting-control checkbox">
|
||||
<label nz-checkbox formControlName="videoFileCompleted"
|
||||
>视频文件完成</label
|
||||
>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-control class="setting-control checkbox">
|
||||
<label nz-checkbox formControlName="danmakuFileCreated"
|
||||
>弹幕文件创建</label
|
||||
>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-control class="setting-control checkbox">
|
||||
<label nz-checkbox formControlName="danmakuFileCompleted"
|
||||
>弹幕文件完成</label
|
||||
>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-control class="setting-control checkbox">
|
||||
<label nz-checkbox formControlName="rawDanmakuFileCreated"
|
||||
>原始弹幕文件创建</label
|
||||
>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-control class="setting-control checkbox">
|
||||
<label nz-checkbox formControlName="rawDanmakuFileCompleted"
|
||||
>原始弹幕文件完成</label
|
||||
>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-control class="setting-control checkbox">
|
||||
<label nz-checkbox formControlName="videoPostprocessingCompleted"
|
||||
>视频后处理完成</label
|
||||
>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
|
||||
@@ -21,8 +21,17 @@ const DEFAULT_SETTINGS = {
|
||||
liveBegan: true,
|
||||
liveEnded: true,
|
||||
roomChange: true,
|
||||
recordingStarted: true,
|
||||
recordingFinished: true,
|
||||
recordingCancelled: true,
|
||||
videoFileCreated: true,
|
||||
videoFileCompleted: true,
|
||||
danmakuFileCreated: true,
|
||||
danmakuFileCompleted: true,
|
||||
rawDanmakuFileCreated: true,
|
||||
rawDanmakuFileCompleted: true,
|
||||
videoPostprocessingCompleted: true,
|
||||
spaceNoEnough: true,
|
||||
fileCompleted: true,
|
||||
errorOccurred: true,
|
||||
} as const;
|
||||
|
||||
@@ -57,7 +66,16 @@ export class WebhookEditDialogComponent implements OnChanges {
|
||||
liveBegan: [''],
|
||||
liveEnded: [''],
|
||||
roomChange: [''],
|
||||
fileCompleted: [''],
|
||||
recordingStarted: [''],
|
||||
recordingFinished: [''],
|
||||
recordingCancelled: [''],
|
||||
videoFileCreated: [''],
|
||||
videoFileCompleted: [''],
|
||||
danmakuFileCreated: [''],
|
||||
danmakuFileCompleted: [''],
|
||||
rawDanmakuFileCreated: [''],
|
||||
rawDanmakuFileCompleted: [''],
|
||||
videoPostprocessingCompleted: [''],
|
||||
spaceNoEnough: [''],
|
||||
errorOccurred: [''],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const breakpoints = [
|
||||
'(max-width: 534.98px)',
|
||||
'(min-width: 535px) and (max-width: 1059.98px)',
|
||||
'(min-width: 1060px)',
|
||||
'(min-width: 535px) and (max-width: 1199.98px)',
|
||||
'(min-width: 1200px)',
|
||||
] as const;
|
||||
|
||||
@@ -259,8 +259,8 @@
|
||||
class="setting-label"
|
||||
nzFor="recordGiftSend"
|
||||
nzNoColon
|
||||
nzTooltipTitle="记录送礼信息到弹幕文件里"
|
||||
>记录送礼</nz-form-label
|
||||
nzTooltipTitle="记录礼物信息到弹幕文件里"
|
||||
>记录礼物</nz-form-label
|
||||
>
|
||||
<nz-form-control class="setting-control switch">
|
||||
<nz-switch
|
||||
@@ -281,6 +281,33 @@
|
||||
>覆盖全局设置</label
|
||||
>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzFor="recordFreeGifts"
|
||||
nzNoColon
|
||||
nzTooltipTitle="记录免费礼物信息到弹幕文件里"
|
||||
>记录免费礼物</nz-form-label
|
||||
>
|
||||
<nz-form-control class="setting-control switch">
|
||||
<nz-switch
|
||||
id="recordFreeGifts"
|
||||
name="recordFreeGifts"
|
||||
[(ngModel)]="model.danmaku.recordFreeGifts"
|
||||
[disabled]="options.danmaku.recordFreeGifts === null"
|
||||
></nz-switch>
|
||||
</nz-form-control>
|
||||
<label
|
||||
nz-checkbox
|
||||
[nzChecked]="options.danmaku.recordFreeGifts !== null"
|
||||
(nzCheckedChange)="
|
||||
options.danmaku.recordFreeGifts = $event
|
||||
? globalSettings.danmaku.recordFreeGifts
|
||||
: null
|
||||
"
|
||||
>覆盖全局设置</label
|
||||
>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
flex: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.selector {
|
||||
min-width: 6em;
|
||||
}
|
||||
}
|
||||
|
||||
.reverse-button {
|
||||
|
||||
@@ -53,8 +53,10 @@ export class ToolbarComponent implements OnInit, OnDestroy {
|
||||
readonly selections = [
|
||||
{ label: '全部', value: DataSelection.ALL },
|
||||
{ label: '录制中', value: DataSelection.RECORDING },
|
||||
{ label: '录制开', value: DataSelection.RECORDER_ENABLED },
|
||||
{ label: '录制关', value: DataSelection.RECORDER_DISABLED },
|
||||
{ label: '停止', value: DataSelection.STOPPED },
|
||||
{ label: '运行', value: DataSelection.MONITOR_ENABLED },
|
||||
{ label: '停止', value: DataSelection.MONITOR_DISABLED },
|
||||
{ label: '直播', value: DataSelection.LIVING },
|
||||
{ label: '轮播', value: DataSelection.ROUNDING },
|
||||
{ label: '闲置', value: DataSelection.PREPARING },
|
||||
|
||||
Reference in New Issue
Block a user