Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c1ed1bbb7 | ||
|
|
996dca3e94 | ||
|
|
d54721d553 | ||
|
|
97b1b3cd02 | ||
|
|
b80019a258 | ||
|
|
74dd739ec7 | ||
|
|
486e2ba552 | ||
|
|
60a8f23a14 | ||
|
|
34d9aa63ef | ||
|
|
6469881220 | ||
|
|
50971eeb0e | ||
|
|
8547834c2a | ||
|
|
08130d5e61 | ||
|
|
46811d4677 | ||
|
|
dd819e65e8 | ||
|
|
4cd3acd09d | ||
|
|
f6249c1fe3 | ||
|
|
57e52c1c01 | ||
|
|
d4a4904d54 | ||
|
|
3a7facce0b | ||
|
|
26b389633e | ||
|
|
2ad6847f42 | ||
|
|
8ad8d53a34 | ||
|
|
6310ce87c6 | ||
|
|
f625e85595 | ||
|
|
121b5f9648 | ||
|
|
91195fb999 | ||
|
|
a64d2d7153 | ||
|
|
f828ffd885 |
4
.github/workflows/portable.yml
vendored
4
.github/workflows/portable.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
env:
|
||||
FFMPEG_ARCHIVE_URL: https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-lgpl-shared.zip
|
||||
FFMPEG_ARCHIVE_NAME: ffmpeg-master-latest-win64-lgpl-shared.zip
|
||||
PYTHON_ARCHIVE_URL: https://www.python.org/ftp/python/3.10.4/python-3.10.4-embed-amd64.zip
|
||||
PYTHON_ARCHIVE_URL: https://www.python.org/ftp/python/3.11.1/python-3.11.1-embed-amd64.zip
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.11.1"
|
||||
|
||||
- name: Download ffmpeg archive
|
||||
run: Invoke-WebRequest -Uri $($env:FFMPEG_ARCHIVE_URL) -OutFile ffmpeg.zip
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -1,5 +1,28 @@
|
||||
# 更新日志
|
||||
|
||||
## 1.13.0
|
||||
|
||||
- 支持 Python 3.11
|
||||
- 改进直播监控
|
||||
- 优化在 Linux 下的内存占用
|
||||
- docker 时区设置为默认 `Asia/Shanghai`
|
||||
|
||||
## 1.12.0
|
||||
|
||||
- 支持自定义 Telegram bot api 地址
|
||||
- 重构弹幕客户端: 避免接收的数据有问题导致崩溃,调整弹幕接收超时时间。
|
||||
|
||||
## 1.11.1
|
||||
|
||||
- 修复 `meta.json` 文件没被删除
|
||||
|
||||
## 1.11.0
|
||||
|
||||
- 改善 HLS 标准录制模式的稳定性
|
||||
- 兼容禁用弹幕的直播间
|
||||
- 支持 Bark 通知
|
||||
- 日志文件改为按天分割
|
||||
|
||||
## 1.10.0
|
||||
|
||||
- 设置 umask 为 000 以确保创建的文件夹权限为 777
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.10-slim-buster
|
||||
FROM python:3.11-slim-buster
|
||||
|
||||
WORKDIR /app
|
||||
VOLUME ["/cfg", "/log", "/rec"]
|
||||
@@ -18,6 +18,7 @@ RUN apt-get update && \
|
||||
ENV DEFAULT_SETTINGS_FILE=/cfg/settings.toml
|
||||
ENV DEFAULT_LOG_DIR=/log
|
||||
ENV DEFAULT_OUT_DIR=/rec
|
||||
ENV TZ="Asia/Shanghai"
|
||||
|
||||
EXPOSE 2233
|
||||
ENTRYPOINT ["blrec", "--host", "0.0.0.0"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.10-slim-buster
|
||||
FROM python:3.11-slim-buster
|
||||
|
||||
WORKDIR /app
|
||||
VOLUME ["/cfg", "/log", "/rec"]
|
||||
@@ -20,6 +20,7 @@ RUN sed -i "s/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g" /etc/apt/sources.li
|
||||
ENV DEFAULT_SETTINGS_FILE=/cfg/settings.toml
|
||||
ENV DEFAULT_LOG_DIR=/log
|
||||
ENV DEFAULT_OUT_DIR=/rec
|
||||
ENV TZ="Asia/Shanghai"
|
||||
|
||||
EXPOSE 2233
|
||||
ENTRYPOINT ["blrec", "--host", "0.0.0.0"]
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
- 支持按文件大小或时长分割文件
|
||||
- 支持转换 `flv` 为 `mp4` 格式(需要安装 `ffmpeg`)
|
||||
- 硬盘空间检测并支持空间不足自动删除旧录播文件。
|
||||
- 事件通知(支持邮箱、`ServerChan`、`pushplus`)
|
||||
- 事件通知(支持邮箱、`ServerChan`、`PushDeer`、`pushplus`、`Telegram`、`Bark` )
|
||||
- `Webhook`(可配合 `REST API` 实现录制控制,录制完成后压制、上传等自定义需求)
|
||||
|
||||
## 前提条件
|
||||
@@ -82,6 +82,7 @@
|
||||
- 默认设置文件位置: `ENV DEFAULT_SETTINGS_FILE=/cfg/settings.toml`
|
||||
- 默认日志存放目录: `ENV DEFAULT_LOG_DIR=/log`
|
||||
- 默认录播存放目录: `ENV DEFAULT_OUT_DIR=/rec`
|
||||
- 默认时区: `ENV TZ="Asia/Shanghai"`
|
||||
|
||||
### 默认参数运行
|
||||
|
||||
@@ -240,11 +241,6 @@ api key 可以使用数字和字母,长度限制为最短 8 最长 80。
|
||||
|
||||
---
|
||||
|
||||
## Thanks
|
||||
|
||||
[](https://jb.gg/OpenSource)
|
||||
|
||||
|
||||
## 其它相关工具或项目
|
||||
|
||||
| 名称 | 链接 | 简介 |
|
||||
|
||||
11
setup.cfg
11
setup.cfg
@@ -38,13 +38,13 @@ install_requires =
|
||||
python-liquid >= 1.2.1, < 2.0.0
|
||||
typing-extensions >= 3.10.0.0
|
||||
ordered-set >= 4.1.0, < 5.0.0
|
||||
fastapi >= 0.70.0, < 0.71.0
|
||||
fastapi >= 0.88.0, < 0.89.0
|
||||
email_validator >= 1.1.3, < 2.0.0
|
||||
click < 8.1.0
|
||||
typer >= 0.4.1, < 0.5.0
|
||||
typer >= 0.7.0, < 0.8.0
|
||||
aiohttp >= 3.8.1, < 4.0.0
|
||||
requests >= 2.24.0, < 3.0.0
|
||||
aiofiles >= 0.8.0, < 0.9.0
|
||||
aiofiles >= 22.1.0, < 23.0.0
|
||||
tenacity >= 8.0.1, < 9.0.0
|
||||
colorama >= 0.4.4, < 0.5.0
|
||||
humanize >= 3.13.1, < 4.0.0
|
||||
@@ -52,13 +52,14 @@ install_requires =
|
||||
attrs >= 21.2.0, < 22.0.0
|
||||
lxml >= 4.6.4, < 5.0.0
|
||||
toml >= 0.10.2, < 0.11.0
|
||||
m3u8 >= 1.0.0, < 2.0.0
|
||||
m3u8 >= 3.3.0, < 4.0.0
|
||||
av >= 10.0.0, < 11.0.0
|
||||
jsonpath == 0.82
|
||||
psutil >= 5.8.0, < 6.0.0
|
||||
reactivex >= 4.0.0, < 5.0.0
|
||||
bitarray >= 2.2.5, < 3.0.0
|
||||
brotli >= 1.0.9, < 2.0.0
|
||||
uvicorn[standard] >= 0.15.0, < 0.16.0
|
||||
uvicorn[standard] >= 0.20.0, < 0.21.0
|
||||
|
||||
[options.extras_require]
|
||||
dev =
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
__prog__ = 'blrec'
|
||||
__version__ = '1.10.0'
|
||||
__version__ = '1.13.0'
|
||||
__github__ = 'https://github.com/acgnhiki/blrec'
|
||||
|
||||
@@ -1,42 +1,36 @@
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Iterator, List, Optional
|
||||
|
||||
import attr
|
||||
import psutil
|
||||
|
||||
from . import __prog__, __version__
|
||||
from .flv.operators import MetaData, StreamProfile
|
||||
from .disk_space import SpaceMonitor, SpaceReclaimer
|
||||
from .bili.helpers import ensure_room_id
|
||||
from .disk_space import SpaceMonitor, SpaceReclaimer
|
||||
from .event.event_submitters import SpaceEventSubmitter
|
||||
from .exception import ExceptionHandler, ExistsError, exception_callback
|
||||
from .flv.operators import MetaData, StreamProfile
|
||||
from .notification import (
|
||||
BarkNotifier,
|
||||
EmailNotifier,
|
||||
PushdeerNotifier,
|
||||
PushplusNotifier,
|
||||
ServerchanNotifier,
|
||||
TelegramNotifier,
|
||||
)
|
||||
from .setting import Settings, SettingsIn, SettingsManager, SettingsOut, TaskOptions
|
||||
from .setting.typing import KeySetOfSettings
|
||||
from .task import (
|
||||
DanmakuFileDetail,
|
||||
RecordTaskManager,
|
||||
TaskData,
|
||||
TaskParam,
|
||||
VideoFileDetail,
|
||||
DanmakuFileDetail,
|
||||
)
|
||||
from .exception import ExistsError, ExceptionHandler, exception_callback
|
||||
from .event.event_submitters import SpaceEventSubmitter
|
||||
from .setting import (
|
||||
SettingsManager,
|
||||
Settings,
|
||||
SettingsIn,
|
||||
SettingsOut,
|
||||
TaskOptions,
|
||||
)
|
||||
from .setting.typing import KeySetOfSettings
|
||||
from .notification import (
|
||||
EmailNotifier,
|
||||
ServerchanNotifier,
|
||||
PushdeerNotifier,
|
||||
PushplusNotifier,
|
||||
TelegramNotifier,
|
||||
)
|
||||
from .webhook import WebHookEmitter
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -104,6 +98,7 @@ class Application:
|
||||
await self.exit()
|
||||
|
||||
async def launch(self) -> None:
|
||||
logger.info('Launching Application...')
|
||||
self._setup()
|
||||
logger.debug(f'Default umask {os.umask(000)}')
|
||||
logger.info(f'Launched Application v{__version__}')
|
||||
@@ -111,10 +106,12 @@ class Application:
|
||||
task.add_done_callback(exception_callback)
|
||||
|
||||
async def exit(self) -> None:
|
||||
logger.info('Exiting Application...')
|
||||
await self._exit()
|
||||
logger.info('Exited Application')
|
||||
|
||||
async def abort(self) -> None:
|
||||
logger.info('Aborting Application...')
|
||||
await self._exit(force=True)
|
||||
logger.info('Aborted Application')
|
||||
|
||||
@@ -127,6 +124,7 @@ class Application:
|
||||
logger.info('Restarting Application...')
|
||||
await self.exit()
|
||||
await self.launch()
|
||||
logger.info('Restarted Application')
|
||||
|
||||
def has_task(self, room_id: int) -> bool:
|
||||
return self._task_manager.has_task(room_id)
|
||||
@@ -135,9 +133,7 @@ class Application:
|
||||
room_id = await ensure_room_id(room_id)
|
||||
|
||||
if self._task_manager.has_task(room_id):
|
||||
raise ExistsError(
|
||||
f'a task for the room {room_id} is already existed'
|
||||
)
|
||||
raise ExistsError(f'a task for the room {room_id} is already existed')
|
||||
|
||||
settings = self._settings_manager.find_task_settings(room_id)
|
||||
if not settings:
|
||||
@@ -213,9 +209,7 @@ class Application:
|
||||
await self._settings_manager.mark_task_recorder_enabled(room_id)
|
||||
logger.info(f'Successfully enabled recorder for task {room_id}')
|
||||
|
||||
async def disable_task_recorder(
|
||||
self, room_id: int, force: bool = False
|
||||
) -> None:
|
||||
async def disable_task_recorder(self, room_id: int, force: bool = False) -> None:
|
||||
logger.info(f'Disabling recorder for task {room_id}...')
|
||||
await self._task_manager.disable_task_recorder(room_id, force)
|
||||
await self._settings_manager.mark_task_recorder_disabled(room_id)
|
||||
@@ -248,9 +242,7 @@ class Application:
|
||||
def get_task_stream_profile(self, room_id: int) -> StreamProfile:
|
||||
return self._task_manager.get_task_stream_profile(room_id)
|
||||
|
||||
def get_task_video_file_details(
|
||||
self, room_id: int
|
||||
) -> Iterator[VideoFileDetail]:
|
||||
def get_task_video_file_details(self, room_id: int) -> Iterator[VideoFileDetail]:
|
||||
yield from self._task_manager.get_task_video_file_details(room_id)
|
||||
|
||||
def get_task_danmaku_file_details(
|
||||
@@ -290,9 +282,7 @@ class Application:
|
||||
async def change_task_options(
|
||||
self, room_id: int, options: TaskOptions
|
||||
) -> TaskOptions:
|
||||
return await self._settings_manager.change_task_options(
|
||||
room_id, options
|
||||
)
|
||||
return await self._settings_manager.change_task_options(room_id, options)
|
||||
|
||||
def _setup(self) -> None:
|
||||
self._setup_logger()
|
||||
@@ -319,9 +309,7 @@ class Application:
|
||||
self._space_event_submitter = SpaceEventSubmitter(self._space_monitor)
|
||||
|
||||
def _setup_space_reclaimer(self) -> None:
|
||||
self._space_reclaimer = SpaceReclaimer(
|
||||
self._space_monitor, self._out_dir,
|
||||
)
|
||||
self._space_reclaimer = SpaceReclaimer(self._space_monitor, self._out_dir)
|
||||
self._settings_manager.apply_space_reclaimer_settings()
|
||||
self._space_reclaimer.enable()
|
||||
|
||||
@@ -331,11 +319,13 @@ class Application:
|
||||
self._pushdeer_notifier = PushdeerNotifier()
|
||||
self._pushplus_notifier = PushplusNotifier()
|
||||
self._telegram_notifier = TelegramNotifier()
|
||||
self._bark_notifier = BarkNotifier()
|
||||
self._settings_manager.apply_email_notification_settings()
|
||||
self._settings_manager.apply_serverchan_notification_settings()
|
||||
self._settings_manager.apply_pushdeer_notification_settings()
|
||||
self._settings_manager.apply_pushplus_notification_settings()
|
||||
self._settings_manager.apply_telegram_notification_settings()
|
||||
self._settings_manager.apply_bark_notification_settings()
|
||||
|
||||
def _setup_webhooks(self) -> None:
|
||||
self._webhook_emitter = WebHookEmitter()
|
||||
@@ -367,11 +357,13 @@ class Application:
|
||||
self._pushdeer_notifier.disable()
|
||||
self._pushplus_notifier.disable()
|
||||
self._telegram_notifier.disable()
|
||||
self._bark_notifier.disable()
|
||||
del self._email_notifier
|
||||
del self._serverchan_notifier
|
||||
del self._pushdeer_notifier
|
||||
del self._pushplus_notifier
|
||||
del self._telegram_notifier
|
||||
del self._bark_notifier
|
||||
|
||||
def _destroy_webhooks(self) -> None:
|
||||
self._webhook_emitter.disable()
|
||||
|
||||
@@ -52,7 +52,7 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
|
||||
webapi: WebApi,
|
||||
room_id: int,
|
||||
*,
|
||||
max_retries: int = 10,
|
||||
max_retries: int = 60,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
@@ -237,7 +237,11 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
|
||||
try:
|
||||
await self._ws.send_bytes(data)
|
||||
except Exception as exc:
|
||||
logger.debug(f'Failed to send heartbeat due to: {repr(exc)}')
|
||||
logger.warning(f'Failed to send heartbeat: {repr(exc)}')
|
||||
await self._emit('error_occurred', exc)
|
||||
task = asyncio.create_task(self.restart())
|
||||
task.add_done_callback(exception_callback)
|
||||
break
|
||||
await asyncio.sleep(self._HEARTBEAT_INTERVAL)
|
||||
|
||||
async def _create_message_loop(self) -> None:
|
||||
@@ -261,45 +265,53 @@ class DanmakuClient(EventEmitter[DanmakuListener], AsyncStoppableMixin):
|
||||
await self._emit('danmaku_received', msg)
|
||||
|
||||
async def _receive(self) -> List[Dict[str, Any]]:
|
||||
self._retry_count = 0
|
||||
self._retry_delay = 0
|
||||
self._reset_retry()
|
||||
|
||||
while True:
|
||||
try:
|
||||
wsmsg = await self._ws.receive(timeout=self._HEARTBEAT_INTERVAL)
|
||||
wsmsg = await self._ws.receive(timeout=self._HEARTBEAT_INTERVAL * 2)
|
||||
except Exception as e:
|
||||
await self._handle_error(e)
|
||||
await self._handle_receive_error(e)
|
||||
else:
|
||||
if wsmsg.type == aiohttp.WSMsgType.BINARY:
|
||||
if result := await self._handle_data(wsmsg.data):
|
||||
return result
|
||||
elif wsmsg.type == aiohttp.WSMsgType.ERROR:
|
||||
await self._handle_error(cast(Exception, wsmsg.data))
|
||||
await self._handle_receive_error(cast(Exception, wsmsg.data))
|
||||
elif wsmsg.type == aiohttp.WSMsgType.CLOSED:
|
||||
msg = 'WebSocket Closed'
|
||||
exc = aiohttp.WebSocketError(self._ws.close_code or 1006, msg)
|
||||
await self._handle_error(exc)
|
||||
await self._handle_receive_error(exc)
|
||||
else:
|
||||
await self._handle_error(ValueError(wsmsg))
|
||||
await self._handle_receive_error(ValueError(wsmsg))
|
||||
|
||||
@staticmethod
|
||||
async def _handle_data(data: bytes) -> Optional[List[Dict[str, Any]]]:
|
||||
loop = asyncio.get_running_loop()
|
||||
op, msg = await loop.run_in_executor(None, Frame.decode, data)
|
||||
|
||||
if op == WS.OP_MESSAGE:
|
||||
msg = cast(List[str], msg)
|
||||
return [json.loads(m) for m in msg]
|
||||
elif op == WS.OP_HEARTBEAT_REPLY:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
try:
|
||||
op, msg = await loop.run_in_executor(None, Frame.decode, data)
|
||||
if op == WS.OP_MESSAGE:
|
||||
msg = cast(List[str], msg)
|
||||
return [json.loads(m) for m in msg]
|
||||
elif op == WS.OP_HEARTBEAT_REPLY:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to handle data: {repr(e)}, data: {repr(data)}')
|
||||
|
||||
async def _handle_error(self, exc: Exception) -> None:
|
||||
logger.debug(f'Failed to receive message due to: {repr(exc)}')
|
||||
return None
|
||||
|
||||
async def _handle_receive_error(self, exc: Exception) -> None:
|
||||
logger.warning(f'Failed to receive message: {repr(exc)}')
|
||||
await self._emit('error_occurred', exc)
|
||||
if isinstance(exc, asyncio.TimeoutError):
|
||||
return
|
||||
await self._retry()
|
||||
|
||||
def _reset_retry(self) -> None:
|
||||
self._retry_count = 0
|
||||
self._retry_delay = 0
|
||||
|
||||
async def _retry(self) -> None:
|
||||
if self._retry_count < self._MAX_RETRIES:
|
||||
if self._retry_delay > 0:
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from contextlib import suppress
|
||||
|
||||
from blrec.exception import exception_callback
|
||||
from blrec.logging.room_id import aio_task_with_room_id
|
||||
|
||||
from .danmaku_client import DanmakuClient, DanmakuListener, DanmakuCommand
|
||||
from .live import Live
|
||||
from .typing import Danmaku
|
||||
from .models import LiveStatus, RoomInfo
|
||||
from ..event.event_emitter import EventListener, EventEmitter
|
||||
from ..event.event_emitter import EventEmitter, EventListener
|
||||
from ..utils.mixins import SwitchableMixin
|
||||
|
||||
from .danmaku_client import DanmakuClient, DanmakuCommand, DanmakuListener
|
||||
from .live import Live
|
||||
from .models import LiveStatus, RoomInfo
|
||||
from .typing import Danmaku
|
||||
|
||||
__all__ = 'LiveMonitor', 'LiveEventListener'
|
||||
|
||||
@@ -37,9 +41,7 @@ class LiveEventListener(EventListener):
|
||||
...
|
||||
|
||||
|
||||
class LiveMonitor(
|
||||
EventEmitter[LiveEventListener], DanmakuListener, SwitchableMixin
|
||||
):
|
||||
class LiveMonitor(EventEmitter[LiveEventListener], DanmakuListener, SwitchableMixin):
|
||||
def __init__(self, danmaku_client: DanmakuClient, live: Live) -> None:
|
||||
super().__init__()
|
||||
self._danmaku_client = danmaku_client
|
||||
@@ -49,18 +51,49 @@ class LiveMonitor(
|
||||
self._previous_status = self._live.room_info.live_status
|
||||
if self._live.is_living():
|
||||
self._status_count = 2
|
||||
self._stream_available = True
|
||||
else:
|
||||
self._status_count = 0
|
||||
self._stream_available = False
|
||||
|
||||
def _do_enable(self) -> None:
|
||||
self._init_status()
|
||||
self._danmaku_client.add_listener(self)
|
||||
self._start_polling()
|
||||
logger.debug('Enabled live monitor')
|
||||
|
||||
def _do_disable(self) -> None:
|
||||
self._danmaku_client.remove_listener(self)
|
||||
asyncio.create_task(self._stop_polling())
|
||||
asyncio.create_task(self._stop_checking())
|
||||
logger.debug('Disabled live monitor')
|
||||
|
||||
def _start_polling(self) -> None:
|
||||
self._polling_task = asyncio.create_task(self._poll_live_status())
|
||||
self._polling_task.add_done_callback(exception_callback)
|
||||
logger.debug('Started polling live status')
|
||||
|
||||
async def _stop_polling(self) -> None:
|
||||
self._polling_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._polling_task
|
||||
del self._polling_task
|
||||
logger.debug('Stopped polling live status')
|
||||
|
||||
def _start_checking(self) -> None:
|
||||
self._checking_task = asyncio.create_task(self._check_if_stream_available())
|
||||
self._checking_task.add_done_callback(exception_callback)
|
||||
logger.debug('Started checking if stream available')
|
||||
|
||||
async def _stop_checking(self) -> None:
|
||||
if not hasattr(self, '_checking_task'):
|
||||
return
|
||||
self._checking_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await self._checking_task
|
||||
del self._checking_task
|
||||
logger.debug('Stopped checking if stream available')
|
||||
|
||||
async def on_client_reconnected(self) -> None:
|
||||
# check the live status after the client reconnected and simulate
|
||||
# events if necessary.
|
||||
@@ -89,8 +122,10 @@ class LiveMonitor(
|
||||
danmu_cmd = danmu['cmd']
|
||||
|
||||
if danmu_cmd == DanmakuCommand.LIVE.value:
|
||||
await self._live.update_room_info()
|
||||
await self._handle_status_change(LiveStatus.LIVE)
|
||||
elif danmu_cmd == DanmakuCommand.PREPARING.value:
|
||||
await self._live.update_room_info()
|
||||
if danmu.get('round', None) == 1:
|
||||
await self._handle_status_change(LiveStatus.ROUND)
|
||||
else:
|
||||
@@ -100,41 +135,57 @@ class LiveMonitor(
|
||||
await self._emit('room_changed', self._live.room_info)
|
||||
|
||||
async def _handle_status_change(self, current_status: LiveStatus) -> None:
|
||||
logger.debug('Live status changed from {} to {}'.format(
|
||||
self._previous_status.name, current_status.name
|
||||
))
|
||||
|
||||
await self._live.update_room_info()
|
||||
if (s := self._live.room_info.live_status) != current_status:
|
||||
logger.warning(
|
||||
'Updated live status {} is inconsistent with '
|
||||
'current live status {}'.format(s.name, current_status.name)
|
||||
logger.debug(
|
||||
'Live status changed from {} to {}'.format(
|
||||
self._previous_status.name, current_status.name
|
||||
)
|
||||
|
||||
await self._emit(
|
||||
'live_status_changed', current_status, self._previous_status
|
||||
)
|
||||
|
||||
await self._emit('live_status_changed', current_status, self._previous_status)
|
||||
|
||||
if current_status != LiveStatus.LIVE:
|
||||
self._status_count = 0
|
||||
self._stream_available = False
|
||||
await self._emit('live_ended', self._live)
|
||||
else:
|
||||
self._status_count += 1
|
||||
|
||||
if self._status_count == 1:
|
||||
assert self._previous_status != LiveStatus.LIVE
|
||||
self._start_checking()
|
||||
await self._emit('live_began', self._live)
|
||||
elif self._status_count == 2:
|
||||
assert self._previous_status == LiveStatus.LIVE
|
||||
await self._emit('live_stream_available', self._live)
|
||||
if not self._stream_available:
|
||||
self._stream_available = True
|
||||
await self._stop_checking()
|
||||
await self._emit('live_stream_available', self._live)
|
||||
elif self._status_count > 2:
|
||||
assert self._previous_status == LiveStatus.LIVE
|
||||
await self._emit('live_stream_reset', self._live)
|
||||
else:
|
||||
pass
|
||||
|
||||
logger.debug('Number of sequential LIVE status: {}'.format(
|
||||
self._status_count
|
||||
))
|
||||
logger.debug('Number of sequential LIVE status: {}'.format(self._status_count))
|
||||
|
||||
self._previous_status = current_status
|
||||
|
||||
@aio_task_with_room_id
|
||||
async def _poll_live_status(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(600 + random.randrange(-60, 60))
|
||||
await self._live.update_room_info()
|
||||
current_status = self._live.room_info.live_status
|
||||
if current_status != self._previous_status:
|
||||
await self._handle_status_change(current_status)
|
||||
|
||||
@aio_task_with_room_id
|
||||
async def _check_if_stream_available(self) -> None:
|
||||
while not self._stream_available:
|
||||
try:
|
||||
await self._live.get_live_stream_url()
|
||||
except Exception:
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
self._stream_available = True
|
||||
await self._emit('live_stream_available', self._live)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from reactivex import operators as ops
|
||||
from reactivex.scheduler import NewThreadScheduler
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.bili.typing import QualityNumber
|
||||
from blrec.hls import operators as hls_ops
|
||||
from blrec.hls.metadata_dumper import MetadataDumper
|
||||
from blrec.utils import operators as utils_ops
|
||||
|
||||
from . import operators as core_ops
|
||||
from .stream_recorder_impl import StreamRecorderImpl
|
||||
@@ -48,7 +48,9 @@ class HLSRawStreamRecorderImpl(StreamRecorderImpl):
|
||||
|
||||
self._playlist_fetcher = hls_ops.PlaylistFetcher(self._live, self._session)
|
||||
self._playlist_dumper = hls_ops.PlaylistDumper(self._path_provider)
|
||||
self._segment_fetcher = hls_ops.SegmentFetcher(self._live, self._session)
|
||||
self._segment_fetcher = hls_ops.SegmentFetcher(
|
||||
self._live, self._session, self._stream_url_resolver
|
||||
)
|
||||
self._segment_dumper = hls_ops.SegmentDumper(self._playlist_dumper)
|
||||
self._ff_metadata_dumper = MetadataDumper(
|
||||
self._playlist_dumper, self._metadata_provider
|
||||
@@ -82,16 +84,14 @@ class HLSRawStreamRecorderImpl(StreamRecorderImpl):
|
||||
self._stream_param_holder.get_stream_params() # type: ignore
|
||||
.pipe(
|
||||
self._stream_url_resolver,
|
||||
ops.subscribe_on(
|
||||
NewThreadScheduler(self._thread_factory('PlaylistDownloader'))
|
||||
),
|
||||
self._playlist_fetcher,
|
||||
self._recording_monitor,
|
||||
self._connection_error_handler,
|
||||
self._request_exception_handler,
|
||||
self._playlist_dumper,
|
||||
ops.observe_on(
|
||||
NewThreadScheduler(self._thread_factory('SegmentDownloader'))
|
||||
utils_ops.observe_on_new_thread(
|
||||
queue_size=60,
|
||||
thread_name=f'SegmentDownloader::{self._live.room_id}',
|
||||
),
|
||||
self._segment_fetcher,
|
||||
self._dl_statistics,
|
||||
@@ -101,5 +101,10 @@ class HLSRawStreamRecorderImpl(StreamRecorderImpl):
|
||||
self._progress_bar,
|
||||
self._exception_handler,
|
||||
)
|
||||
.subscribe(on_completed=self._on_completed)
|
||||
.subscribe(
|
||||
on_completed=self._on_completed,
|
||||
scheduler=NewThreadScheduler(
|
||||
self._thread_factory('HLSRawStreamRecorder')
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from reactivex import operators as ops
|
||||
from reactivex.scheduler import NewThreadScheduler
|
||||
|
||||
from blrec.bili.live import Live
|
||||
@@ -9,6 +8,7 @@ from blrec.bili.typing import QualityNumber
|
||||
from blrec.flv import operators as flv_ops
|
||||
from blrec.flv.metadata_dumper import MetadataDumper
|
||||
from blrec.hls import operators as hls_ops
|
||||
from blrec.utils import operators as utils_ops
|
||||
|
||||
from . import operators as core_ops
|
||||
from .stream_recorder_impl import StreamRecorderImpl
|
||||
@@ -48,16 +48,16 @@ class HLSStreamRecorderImpl(StreamRecorderImpl):
|
||||
)
|
||||
|
||||
self._playlist_fetcher = hls_ops.PlaylistFetcher(self._live, self._session)
|
||||
self._playlist_resolver = hls_ops.PlaylistResolver()
|
||||
self._segment_fetcher = hls_ops.SegmentFetcher(self._live, self._session)
|
||||
self._playlist_resolver = hls_ops.PlaylistResolver(self._stream_url_resolver)
|
||||
self._segment_fetcher = hls_ops.SegmentFetcher(
|
||||
self._live, self._session, self._stream_url_resolver
|
||||
)
|
||||
self._segment_remuxer = hls_ops.SegmentRemuxer(live)
|
||||
|
||||
self._prober = hls_ops.Prober()
|
||||
self._dl_statistics = core_ops.SizedStatistics()
|
||||
|
||||
self._stream_parser = core_ops.StreamParser(
|
||||
self._stream_param_holder, ignore_eof=True, ignore_value_error=True
|
||||
)
|
||||
self._segment_parser = hls_ops.SegmentParser()
|
||||
self._analyser = flv_ops.Analyser()
|
||||
self._injector = flv_ops.Injector(self._metadata_provider)
|
||||
self._join_point_extractor = flv_ops.JoinPointExtractor()
|
||||
@@ -130,28 +130,22 @@ class HLSStreamRecorderImpl(StreamRecorderImpl):
|
||||
self._stream_param_holder.get_stream_params() # type: ignore
|
||||
.pipe(
|
||||
self._stream_url_resolver,
|
||||
ops.subscribe_on(
|
||||
NewThreadScheduler(self._thread_factory('PlaylistFetcher'))
|
||||
),
|
||||
self._playlist_fetcher,
|
||||
self._recording_monitor,
|
||||
self._connection_error_handler,
|
||||
self._request_exception_handler,
|
||||
self._playlist_resolver,
|
||||
ops.observe_on(
|
||||
NewThreadScheduler(self._thread_factory('SegmentFetcher'))
|
||||
utils_ops.observe_on_new_thread(
|
||||
queue_size=60, thread_name=f'SegmentFetcher::{self._live.room_id}'
|
||||
),
|
||||
self._segment_fetcher,
|
||||
self._dl_statistics,
|
||||
self._prober,
|
||||
ops.observe_on(
|
||||
NewThreadScheduler(self._thread_factory('SegmentRemuxer'))
|
||||
utils_ops.observe_on_new_thread(
|
||||
queue_size=10, thread_name=f'StreamRecorder::{self._live.room_id}'
|
||||
),
|
||||
self._segment_remuxer,
|
||||
ops.observe_on(
|
||||
NewThreadScheduler(self._thread_factory('StreamRecorder'))
|
||||
),
|
||||
self._stream_parser,
|
||||
self._segment_parser,
|
||||
flv_ops.process(),
|
||||
self._cutter,
|
||||
self._limiter,
|
||||
@@ -163,5 +157,8 @@ class HLSStreamRecorderImpl(StreamRecorderImpl):
|
||||
self._progress_bar,
|
||||
self._exception_handler,
|
||||
)
|
||||
.subscribe(on_completed=self._on_completed)
|
||||
.subscribe(
|
||||
on_completed=self._on_completed,
|
||||
scheduler=NewThreadScheduler(self._thread_factory('HLSStreamRecorder')),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
from reactivex import Observable, abc
|
||||
from reactivex import operators as ops
|
||||
|
||||
@@ -29,7 +28,6 @@ __all__ = ('StreamURLResolver',)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.getLogger(urllib3.__name__).setLevel(logging.WARNING)
|
||||
|
||||
|
||||
class StreamURLResolver(AsyncCooperationMixin):
|
||||
@@ -49,14 +47,14 @@ class StreamURLResolver(AsyncCooperationMixin):
|
||||
def stream_host(self) -> str:
|
||||
return self._stream_host
|
||||
|
||||
def _reset(self) -> None:
|
||||
def reset(self) -> None:
|
||||
self._stream_url = ''
|
||||
self._stream_host = ''
|
||||
self._stream_params = None
|
||||
|
||||
def __call__(self, source: Observable[StreamParams]) -> Observable[str]:
|
||||
self._reset()
|
||||
return self._solve(source).pipe(
|
||||
self.reset()
|
||||
return self._solve(source).pipe( # type: ignore
|
||||
ops.do_action(on_error=self._before_retry),
|
||||
utils_ops.retry(delay=1, should_retry=self._should_retry),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,6 @@ from blrec.bili.models import RoomInfo
|
||||
from blrec.bili.typing import QualityNumber, StreamFormat
|
||||
from blrec.event.event_emitter import EventEmitter, EventListener
|
||||
from blrec.flv.operators import MetaData, StreamProfile
|
||||
from blrec.logging.room_id import aio_task_with_room_id
|
||||
from blrec.setting.typing import RecordingMode
|
||||
from blrec.utils.mixins import AsyncStoppableMixin
|
||||
|
||||
@@ -457,8 +456,6 @@ class Recorder(
|
||||
await self._prepare()
|
||||
if self._stream_available:
|
||||
await self._stream_recorder.start()
|
||||
else:
|
||||
asyncio.create_task(self._guard())
|
||||
|
||||
logger.info('Started recording')
|
||||
await self._emit('recording_started', self)
|
||||
@@ -489,29 +486,6 @@ class Recorder(
|
||||
self._danmaku_dumper.clear_files()
|
||||
self._stream_recorder.clear_files()
|
||||
|
||||
@aio_task_with_room_id
|
||||
async def _guard(self, timeout: float = 60) -> None:
|
||||
await asyncio.sleep(timeout)
|
||||
if not self._recording:
|
||||
return
|
||||
if self._stream_available:
|
||||
return
|
||||
logger.debug(
|
||||
f'Stream not available in {timeout} seconds, the event maybe lost.'
|
||||
)
|
||||
|
||||
await self._live.update_info()
|
||||
if self._live.is_living():
|
||||
logger.debug('The live is living now')
|
||||
self._stream_available = True
|
||||
if self._stream_recorder.stopped:
|
||||
await self._stream_recorder.start()
|
||||
else:
|
||||
logger.debug('The live has ended before streaming')
|
||||
self._stream_available = False
|
||||
if not self._stream_recorder.stopped:
|
||||
await self.stop()
|
||||
|
||||
def _print_waiting_message(self) -> None:
|
||||
logger.info('Waiting... until the live starts')
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from blrec.bili.typing import QualityNumber, StreamFormat
|
||||
from blrec.event.event_emitter import EventEmitter
|
||||
from blrec.flv.operators import MetaData, StreamProfile
|
||||
from blrec.setting.typing import RecordingMode
|
||||
from blrec.utils.libc import malloc_trim
|
||||
from blrec.utils.mixins import AsyncStoppableMixin
|
||||
|
||||
from .flv_stream_recorder_impl import FLVStreamRecorderImpl
|
||||
@@ -255,6 +256,7 @@ class StreamRecorder(
|
||||
|
||||
async def _do_stop(self) -> None:
|
||||
await self._impl.stop()
|
||||
malloc_trim(0)
|
||||
|
||||
async def on_video_file_created(self, path: str, record_start_time: int) -> None:
|
||||
await self._emit('video_file_created', path, record_start_time)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import io
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
@@ -75,8 +76,8 @@ class StreamRecorderImpl(
|
||||
self._session = requests.Session()
|
||||
|
||||
self._recording_mode = recording_mode
|
||||
self._buffer_size = buffer_size
|
||||
self._read_timeout = read_timeout
|
||||
self._buffer_size = buffer_size or io.DEFAULT_BUFFER_SIZE
|
||||
self._read_timeout = read_timeout or 3
|
||||
self._filesize_limit = filesize_limit
|
||||
self._duration_limit = duration_limit
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
1
src/blrec/data/webapp/548.4789e17f7acce023.js
Normal file
1
src/blrec/data/webapp/548.4789e17f7acce023.js
Normal file
File diff suppressed because one or more lines are too long
1
src/blrec/data/webapp/91.07ca0767ccc21566.js
Normal file
1
src/blrec/data/webapp/91.07ca0767ccc21566.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.c6818dbcd7b06106.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.6da8ea192405b948.js" type="module"></script>
|
||||
<script src="runtime.a904720a2e39ffc3.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.dbd09d2079405adc.js" type="module"></script>
|
||||
|
||||
</body></html>
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"configVersion": 1,
|
||||
"timestamp": 1661579095139,
|
||||
"timestamp": 1669447100875,
|
||||
"index": "/index.html",
|
||||
"assetGroups": [
|
||||
{
|
||||
@@ -14,15 +14,15 @@
|
||||
"/103.5b5d2a6e5a8a7479.js",
|
||||
"/146.5a8902910bda9e87.js",
|
||||
"/183.ee55fc76717674c3.js",
|
||||
"/205.cf2caa9b46b14212.js",
|
||||
"/45.c90c3cea2bf1a66e.js",
|
||||
"/91.cab8652a2fa56b1a.js",
|
||||
"/548.4789e17f7acce023.js",
|
||||
"/91.07ca0767ccc21566.js",
|
||||
"/common.858f777e9296e6f2.js",
|
||||
"/index.html",
|
||||
"/main.6da8ea192405b948.js",
|
||||
"/main.dbd09d2079405adc.js",
|
||||
"/manifest.webmanifest",
|
||||
"/polyfills.4b08448aee19bb22.js",
|
||||
"/runtime.c6818dbcd7b06106.js",
|
||||
"/runtime.a904720a2e39ffc3.js",
|
||||
"/styles.2e152d608221c2ee.css"
|
||||
],
|
||||
"patterns": []
|
||||
@@ -1637,9 +1637,9 @@
|
||||
"/103.5b5d2a6e5a8a7479.js": "cc0240f217015b6d4ddcc14f31fcc42e1c1c282a",
|
||||
"/146.5a8902910bda9e87.js": "d9c33c7073662699f00f46f3a384ae5b749fdef9",
|
||||
"/183.ee55fc76717674c3.js": "2628c996ec80a6c6703d542d34ac95194283bcf8",
|
||||
"/205.cf2caa9b46b14212.js": "749df896fbbd279dcf49318963f0ce074c5df87f",
|
||||
"/45.c90c3cea2bf1a66e.js": "e5bfb8cf3803593e6b8ea14c90b3d3cb6a066764",
|
||||
"/91.cab8652a2fa56b1a.js": "c11ebf28472c8a75653f7b27b5cffdec477830fe",
|
||||
"/548.4789e17f7acce023.js": "3b8aaf921bd400fb32cc15135dd4de09deb2c824",
|
||||
"/91.07ca0767ccc21566.js": "4105beda647cedabf52678640e8fe450671e2e45",
|
||||
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
|
||||
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
|
||||
"/assets/fill/.gitkeep": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
|
||||
@@ -3234,11 +3234,11 @@
|
||||
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
|
||||
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
|
||||
"/common.858f777e9296e6f2.js": "b68ca68e1e214a2537d96935c23410126cc564dd",
|
||||
"/index.html": "80797fa46f33b7bcf402788a5d0d0516b77f23b1",
|
||||
"/main.6da8ea192405b948.js": "b8995c7d8ccd465769b90936db5e0a337a827a58",
|
||||
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
|
||||
"/index.html": "9ba0d26d371e607af065904e06d098a0698f75a3",
|
||||
"/main.dbd09d2079405adc.js": "2f7284b616ed9fc433b612c9dca53dc06a0f3aa1",
|
||||
"/manifest.webmanifest": "0c4534b4c868d756691b1b4372cecb2efce47c6d",
|
||||
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
|
||||
"/runtime.c6818dbcd7b06106.js": "00160f946c5d007a956f5f61293cbd3bed2756dc",
|
||||
"/runtime.a904720a2e39ffc3.js": "d9eb86363e3840a15e5659af6f04f29e19df9233",
|
||||
"/styles.2e152d608221c2ee.css": "9830389a46daa5b4511e0dd343aad23ca9f9690f"
|
||||
},
|
||||
"navigationUrls": [
|
||||
|
||||
1
src/blrec/data/webapp/runtime.a904720a2e39ffc3.js
Normal file
1
src/blrec/data/webapp/runtime.a904720a2e39ffc3.js
Normal file
@@ -0,0 +1 @@
|
||||
(()=>{"use strict";var e,v={},m={};function r(e){var f=m[e];if(void 0!==f)return f.exports;var t=m[e]={exports:{}};return v[e].call(t.exports,t,t.exports,r),t.exports}r.m=v,e=[],r.O=(f,t,i,o)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,i,o]=e[n],c=!0,l=0;l<t.length;l++)(!1&o||a>=o)&&Object.keys(r.O).every(b=>r.O[b](t[l]))?t.splice(l--,1):(c=!1,o<a&&(a=o));if(c){e.splice(n--,1);var d=i();void 0!==d&&(f=d)}}return f}o=o||0;for(var n=e.length;n>0&&e[n-1][2]>o;n--)e[n]=e[n-1];e[n]=[t,i,o]},r.n=e=>{var f=e&&e.__esModule?()=>e.default:()=>e;return r.d(f,{a:f}),f},r.d=(e,f)=>{for(var t in f)r.o(f,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:f[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((f,t)=>(r.f[t](e,f),f),[])),r.u=e=>(592===e?"common":e)+"."+{45:"c90c3cea2bf1a66e",91:"07ca0767ccc21566",103:"5b5d2a6e5a8a7479",146:"5a8902910bda9e87",183:"ee55fc76717674c3",548:"4789e17f7acce023",592:"858f777e9296e6f2"}[e]+".js",r.miniCssF=e=>{},r.o=(e,f)=>Object.prototype.hasOwnProperty.call(e,f),(()=>{var e={},f="blrec:";r.l=(t,i,o,n)=>{if(e[t])e[t].push(i);else{var a,c;if(void 0!==o)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")==f+o){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",f+o),a.src=r.tu(t)),e[t]=[i];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=f=>(void 0===e&&(e={createScriptURL:t=>t},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e.createScriptURL(f))})(),r.p="",(()=>{var e={666:0};r.f.j=(i,o)=>{var n=r.o(e,i)?e[i]:void 0;if(0!==n)if(n)o.push(n[2]);else if(666!=i){var a=new Promise((u,s)=>n=e[i]=[u,s]);o.push(n[2]=a);var c=r.p+r.u(i),l=new Error;r.l(c,u=>{if(r.o(e,i)&&(0!==(n=e[i])&&(e[i]=void 0),n)){var s=u&&("load"===u.type?"missing":u.type),p=u&&u.target&&u.target.src;l.message="Loading chunk "+i+" failed.\n("+s+": "+p+")",l.name="ChunkLoadError",l.type=s,l.request=p,n[1](l)}},"chunk-"+i,i)}else e[i]=0},r.O.j=i=>0===e[i];var f=(i,o)=>{var l,d,[n,a,c]=o,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(i&&i(o);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(f.bind(null,0)),t.push=f.bind(null,t.push.bind(t))})()})();
|
||||
@@ -1 +0,0 @@
|
||||
(()=>{"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,f,o)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,f,o]=e[n],c=!0,l=0;l<t.length;l++)(!1&o||a>=o)&&Object.keys(r.O).every(p=>r.O[p](t[l]))?t.splice(l--,1):(c=!1,o<a&&(a=o));if(c){e.splice(n--,1);var d=f();void 0!==d&&(i=d)}}return i}o=o||0;for(var n=e.length;n>0&&e[n-1][2]>o;n--)e[n]=e[n-1];e[n]=[t,f,o]},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)+"."+{45:"c90c3cea2bf1a66e",91:"cab8652a2fa56b1a",103:"5b5d2a6e5a8a7479",146:"5a8902910bda9e87",183:"ee55fc76717674c3",205:"cf2caa9b46b14212",592:"858f777e9296e6f2"}[e]+".js",r.miniCssF=e=>{},r.o=(e,i)=>Object.prototype.hasOwnProperty.call(e,i),(()=>{var e={},i="blrec:";r.l=(t,f,o,n)=>{if(e[t])e[t].push(f);else{var a,c;if(void 0!==o)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+o){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+o),a.src=r.tu(t)),e[t]=[f];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=(f,o)=>{var n=r.o(e,f)?e[f]:void 0;if(0!==n)if(n)o.push(n[2]);else if(666!=f){var a=new Promise((u,s)=>n=e[f]=[u,s]);o.push(n[2]=a);var c=r.p+r.u(f),l=new Error;r.l(c,u=>{if(r.o(e,f)&&(0!==(n=e[f])&&(e[f]=void 0),n)){var s=u&&("load"===u.type?"missing":u.type),b=u&&u.target&&u.target.src;l.message="Loading chunk "+f+" failed.\n("+s+": "+b+")",l.name="ChunkLoadError",l.type=s,l.request=b,n[1](l)}},"chunk-"+f,f)}else e[f]=0},r.O.j=f=>0===e[f];var i=(f,o)=>{var l,d,[n,a,c]=o,u=0;if(n.some(b=>0!==e[b])){for(l in a)r.o(a,l)&&(r.m[l]=a[l]);if(c)var s=c(r)}for(f&&f(o);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))})()})();
|
||||
@@ -1,37 +1,33 @@
|
||||
from io import BytesIO, SEEK_CUR
|
||||
from io import SEEK_CUR, BytesIO
|
||||
from typing import cast
|
||||
|
||||
import attr
|
||||
|
||||
from .struct_io import StructReader, StructWriter
|
||||
from .exceptions import FlvDataError, FlvHeaderError, FlvTagError
|
||||
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,
|
||||
TAG_HEADER_SIZE,
|
||||
VIDEO_TAG_HEADER_SIZE,
|
||||
|
||||
AACPacketType,
|
||||
AudioTag,
|
||||
AudioTagHeader,
|
||||
AVCPacketType,
|
||||
CodecID,
|
||||
FlvHeader,
|
||||
FlvTag,
|
||||
FlvTagHeader,
|
||||
FrameType,
|
||||
ScriptTag,
|
||||
SoundFormat,
|
||||
SoundRate,
|
||||
SoundSize,
|
||||
SoundType,
|
||||
AACPacketType,
|
||||
|
||||
TagType,
|
||||
VideoTag,
|
||||
VideoTagHeader,
|
||||
CodecID,
|
||||
FrameType,
|
||||
AVCPacketType,
|
||||
|
||||
ScriptTag,
|
||||
)
|
||||
|
||||
from .struct_io import StructReader, StructWriter
|
||||
|
||||
__all__ = 'FlvParser', 'FlvDumper'
|
||||
|
||||
@@ -73,7 +69,7 @@ class FlvParser:
|
||||
body_size = tag_header.data_size - AUDIO_TAG_HEADER_SIZE
|
||||
if no_body:
|
||||
self._stream.seek(body_size, SEEK_CUR)
|
||||
body = None
|
||||
body = b''
|
||||
else:
|
||||
body = self._reader.read(body_size)
|
||||
audio_tag_header = self.parse_audio_tag_header(header_data)
|
||||
@@ -88,7 +84,7 @@ class FlvParser:
|
||||
body_size = tag_header.data_size - VIDEO_TAG_HEADER_SIZE
|
||||
if no_body:
|
||||
self._stream.seek(body_size, SEEK_CUR)
|
||||
body = None
|
||||
body = b''
|
||||
else:
|
||||
body = self._reader.read(body_size)
|
||||
video_tag_header = self.parse_video_tag_header(header_data)
|
||||
@@ -102,14 +98,10 @@ class FlvParser:
|
||||
body_size = tag_header.data_size
|
||||
if no_body:
|
||||
self._stream.seek(body_size, SEEK_CUR)
|
||||
body = None
|
||||
body = b''
|
||||
else:
|
||||
body = self._reader.read(body_size)
|
||||
return ScriptTag(
|
||||
offset=offset,
|
||||
**attr.asdict(tag_header),
|
||||
body=body,
|
||||
)
|
||||
return ScriptTag(offset=offset, **attr.asdict(tag_header), body=body)
|
||||
else:
|
||||
raise FlvDataError(f'Unsupported tag type: {tag_header.tag_type}')
|
||||
|
||||
@@ -157,9 +149,7 @@ class FlvParser:
|
||||
flag = reader.read_ui8()
|
||||
sound_format = SoundFormat(flag >> 4)
|
||||
if sound_format != SoundFormat.AAC:
|
||||
raise FlvDataError(
|
||||
f'Unsupported sound format: {sound_format}', data
|
||||
)
|
||||
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)
|
||||
@@ -177,9 +167,7 @@ class FlvParser:
|
||||
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
|
||||
)
|
||||
return VideoTagHeader(frame_type, codec_id, avc_packet_type, composition_time)
|
||||
|
||||
|
||||
class FlvDumper:
|
||||
@@ -221,28 +209,24 @@ class FlvDumper:
|
||||
def dump_flv_tag_header(self, tag: FlvTag) -> None:
|
||||
self._writer.write_ui8((int(tag.filtered) << 5) | tag.tag_type.value)
|
||||
self._writer.write_ui24(tag.data_size)
|
||||
self._writer.write_ui24(tag.timestamp & 0x00ffffff)
|
||||
self._writer.write_ui24(tag.timestamp & 0x00FFFFFF)
|
||||
self._writer.write_ui8(tag.timestamp >> 24)
|
||||
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
|
||||
)
|
||||
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
|
||||
(tag.sound_format.value << 4)
|
||||
| (tag.sound_rate.value << 2)
|
||||
| (tag.sound_size.value << 1)
|
||||
| tag.sound_type.value
|
||||
)
|
||||
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
|
||||
)
|
||||
self._writer.write_ui8((tag.frame_type.value << 4) | tag.codec_id.value)
|
||||
self._writer.write_ui8(tag.avc_packet_type.value)
|
||||
self._writer.write_ui24(tag.composition_time)
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
class SegmentDataCorrupted(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class NoNewSegments(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class FetchSegmentError(Exception):
|
||||
pass
|
||||
|
||||
@@ -4,6 +4,7 @@ from .playlist_resolver import PlaylistResolver
|
||||
from .prober import Prober, StreamProfile
|
||||
from .segment_dumper import SegmentDumper
|
||||
from .segment_fetcher import InitSectionData, SegmentData, SegmentFetcher
|
||||
from .segment_parser import SegmentParser
|
||||
from .segment_remuxer import SegmentRemuxer
|
||||
|
||||
__all__ = (
|
||||
@@ -15,6 +16,7 @@ __all__ = (
|
||||
'SegmentData',
|
||||
'SegmentDumper',
|
||||
'SegmentFetcher',
|
||||
'SegmentParser',
|
||||
'SegmentRemuxer',
|
||||
'StreamProfile',
|
||||
)
|
||||
|
||||
@@ -5,20 +5,30 @@ import os
|
||||
from typing import Optional
|
||||
|
||||
import m3u8
|
||||
import urllib3
|
||||
from reactivex import Observable, abc
|
||||
from reactivex import operators as ops
|
||||
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
|
||||
|
||||
from blrec.core import operators as core_ops
|
||||
from blrec.utils import operators as utils_ops
|
||||
|
||||
from ..exceptions import NoNewSegments
|
||||
|
||||
__all__ = ('PlaylistResolver',)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.getLogger(urllib3.__name__).setLevel(logging.WARNING)
|
||||
|
||||
|
||||
class PlaylistResolver:
|
||||
def __init__(self, stream_url_resolver: core_ops.StreamURLResolver) -> None:
|
||||
self._stream_url_resolver = stream_url_resolver
|
||||
|
||||
def __call__(self, source: Observable[m3u8.M3U8]) -> Observable[m3u8.Segment]:
|
||||
return self._solve(source)
|
||||
return self._solve(source).pipe(
|
||||
ops.do_action(on_error=self._before_retry),
|
||||
utils_ops.retry(should_retry=self._should_retry),
|
||||
)
|
||||
|
||||
def _name_of(self, uri: str) -> str:
|
||||
name, ext = os.path.splitext(uri)
|
||||
@@ -35,18 +45,18 @@ class PlaylistResolver:
|
||||
disposed = False
|
||||
subscription = SerialDisposable()
|
||||
|
||||
attempts: int = 0
|
||||
last_sequence_number: Optional[int] = None
|
||||
|
||||
def on_next(playlist: m3u8.M3U8) -> None:
|
||||
nonlocal last_sequence_number
|
||||
nonlocal attempts, last_sequence_number
|
||||
|
||||
if playlist.is_endlist:
|
||||
logger.debug('Playlist ended')
|
||||
|
||||
new_segments = []
|
||||
for seg in playlist.segments:
|
||||
uri = seg.uri
|
||||
name = self._name_of(uri)
|
||||
num = int(name)
|
||||
num = self._sequence_number_of(seg.uri)
|
||||
if last_sequence_number is not None:
|
||||
if last_sequence_number >= num:
|
||||
continue
|
||||
@@ -57,9 +67,21 @@ class PlaylistResolver:
|
||||
f'current sequence number: {num}'
|
||||
)
|
||||
seg.discontinuity = True
|
||||
observer.on_next(seg)
|
||||
new_segments.append(seg)
|
||||
last_sequence_number = num
|
||||
|
||||
if not new_segments:
|
||||
attempts += 1
|
||||
if attempts > 3:
|
||||
attempts = 0
|
||||
observer.on_error(NoNewSegments())
|
||||
return
|
||||
else:
|
||||
attempts = 0
|
||||
|
||||
for seg in new_segments:
|
||||
observer.on_next(seg)
|
||||
|
||||
def dispose() -> None:
|
||||
nonlocal disposed
|
||||
nonlocal last_sequence_number
|
||||
@@ -73,3 +95,15 @@ class PlaylistResolver:
|
||||
return CompositeDisposable(subscription, Disposable(dispose))
|
||||
|
||||
return Observable(subscribe)
|
||||
|
||||
def _should_retry(self, exc: Exception) -> bool:
|
||||
if isinstance(exc, NoNewSegments):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def _before_retry(self, exc: Exception) -> None:
|
||||
if not isinstance(exc, NoNewSegments):
|
||||
return
|
||||
logger.warning('No new segments received, trying to update the stream url.')
|
||||
self._stream_url_resolver.reset()
|
||||
|
||||
@@ -10,13 +10,23 @@ import requests
|
||||
import urllib3
|
||||
from m3u8.model import InitializationSection
|
||||
from reactivex import Observable, abc
|
||||
from reactivex import operators as ops
|
||||
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_delay, wait_exponential
|
||||
from tenacity import (
|
||||
retry,
|
||||
retry_all,
|
||||
retry_if_exception_type,
|
||||
retry_if_not_exception_type,
|
||||
stop_after_delay,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.core import operators as core_ops
|
||||
from blrec.utils import operators as utils_ops
|
||||
from blrec.utils.hash import cksum
|
||||
|
||||
from ..exceptions import SegmentDataCorrupted
|
||||
from ..exceptions import FetchSegmentError
|
||||
|
||||
__all__ = ('SegmentFetcher', 'InitSectionData', 'SegmentData')
|
||||
|
||||
@@ -43,14 +53,23 @@ class SegmentData:
|
||||
|
||||
|
||||
class SegmentFetcher:
|
||||
def __init__(self, live: Live, session: requests.Session) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
live: Live,
|
||||
session: requests.Session,
|
||||
stream_url_resolver: core_ops.StreamURLResolver,
|
||||
) -> None:
|
||||
self._live = live
|
||||
self._session = session
|
||||
self._stream_url_resolver = stream_url_resolver
|
||||
|
||||
def __call__(
|
||||
self, source: Observable[m3u8.Segment]
|
||||
) -> Observable[Union[InitSectionData, SegmentData]]:
|
||||
return self._fetch(source)
|
||||
return self._fetch(source).pipe( # type: ignore
|
||||
ops.do_action(on_error=self._before_retry),
|
||||
utils_ops.retry(should_retry=self._should_retry),
|
||||
)
|
||||
|
||||
def _fetch(
|
||||
self, source: Observable[m3u8.Segment]
|
||||
@@ -62,10 +81,11 @@ class SegmentFetcher:
|
||||
disposed = False
|
||||
subscription = SerialDisposable()
|
||||
|
||||
attempts: int = 0
|
||||
last_segment: Optional[m3u8.Segment] = None
|
||||
|
||||
def on_next(seg: m3u8.Segment) -> None:
|
||||
nonlocal last_segment
|
||||
nonlocal attempts, last_segment
|
||||
url: str = ''
|
||||
|
||||
try:
|
||||
@@ -115,11 +135,16 @@ class SegmentFetcher:
|
||||
f'segment url: {url}'
|
||||
)
|
||||
else:
|
||||
raise SegmentDataCorrupted(crc32, crc32_of_data)
|
||||
logger.warning(f'Segment data corrupted: {url}')
|
||||
except Exception as exc:
|
||||
logger.warning(f'Failed to fetch segment {url}', exc_info=exc)
|
||||
attempts += 1
|
||||
if attempts > 3:
|
||||
attempts = 0
|
||||
observer.on_error(FetchSegmentError(exc))
|
||||
else:
|
||||
observer.on_next(SegmentData(segment=seg, payload=data))
|
||||
attempts = 0
|
||||
|
||||
def dispose() -> None:
|
||||
nonlocal disposed
|
||||
@@ -137,8 +162,11 @@ class SegmentFetcher:
|
||||
|
||||
@retry(
|
||||
reraise=True,
|
||||
retry=retry_if_exception_type(
|
||||
(requests.exceptions.RequestException, urllib3.exceptions.HTTPError)
|
||||
retry=retry_all(
|
||||
retry_if_exception_type(
|
||||
(requests.exceptions.RequestException, urllib3.exceptions.HTTPError)
|
||||
),
|
||||
retry_if_not_exception_type(requests.exceptions.HTTPError),
|
||||
),
|
||||
wait=wait_exponential(max=10),
|
||||
stop=stop_after_delay(60),
|
||||
@@ -147,3 +175,17 @@ class SegmentFetcher:
|
||||
with self._session.get(url, headers=self._live.headers, timeout=10) as response:
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
def _should_retry(self, exc: Exception) -> bool:
|
||||
if isinstance(exc, FetchSegmentError):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def _before_retry(self, exc: Exception) -> None:
|
||||
if not isinstance(exc, FetchSegmentError):
|
||||
return
|
||||
logger.warning(
|
||||
'Fetch segments failed continuously, trying to update the stream url.'
|
||||
)
|
||||
self._stream_url_resolver.reset()
|
||||
|
||||
104
src/blrec/hls/operators/segment_parser.py
Normal file
104
src/blrec/hls/operators/segment_parser.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from reactivex import Observable, abc
|
||||
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
|
||||
|
||||
from blrec.flv.common import (
|
||||
is_audio_sequence_header,
|
||||
is_metadata_tag,
|
||||
is_video_sequence_header,
|
||||
)
|
||||
from blrec.flv.io import FlvReader
|
||||
from blrec.flv.models import AudioTag, FlvHeader, ScriptTag, VideoTag
|
||||
from blrec.flv.operators.typing import FLVStream, FLVStreamItem
|
||||
|
||||
__all__ = ('SegmentParser',)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SegmentParser:
|
||||
def __init__(self) -> None:
|
||||
self._backup_timestamp = True
|
||||
|
||||
def __call__(self, source: Observable[bytes]) -> FLVStream:
|
||||
return self._parse(source)
|
||||
|
||||
def _parse(self, source: Observable[bytes]) -> FLVStream:
|
||||
def subscribe(
|
||||
observer: abc.ObserverBase[FLVStreamItem],
|
||||
scheduler: Optional[abc.SchedulerBase] = None,
|
||||
) -> abc.DisposableBase:
|
||||
disposed = False
|
||||
subscription = SerialDisposable()
|
||||
|
||||
last_flv_header: Optional[FlvHeader] = None
|
||||
last_metadata_tag: Optional[ScriptTag] = None
|
||||
last_audio_sequence_header: Optional[AudioTag] = None
|
||||
last_video_sequence_header: Optional[VideoTag] = None
|
||||
|
||||
def reset() -> None:
|
||||
nonlocal last_flv_header, last_metadata_tag
|
||||
nonlocal last_audio_sequence_header, last_video_sequence_header
|
||||
last_flv_header = None
|
||||
last_metadata_tag = None
|
||||
last_audio_sequence_header = None
|
||||
last_video_sequence_header = None
|
||||
|
||||
def on_next(data: bytes) -> None:
|
||||
nonlocal last_flv_header, last_metadata_tag
|
||||
nonlocal last_audio_sequence_header, last_video_sequence_header
|
||||
|
||||
if b'' == data:
|
||||
reset()
|
||||
return
|
||||
|
||||
try:
|
||||
reader = FlvReader(
|
||||
io.BytesIO(data), backup_timestamp=self._backup_timestamp
|
||||
)
|
||||
|
||||
flv_header = reader.read_header()
|
||||
if not last_flv_header:
|
||||
observer.on_next(flv_header)
|
||||
last_flv_header = flv_header
|
||||
else:
|
||||
assert last_flv_header == flv_header
|
||||
|
||||
while not disposed:
|
||||
tag = reader.read_tag()
|
||||
if is_metadata_tag(tag):
|
||||
if last_metadata_tag is not None:
|
||||
continue
|
||||
last_metadata_tag = tag
|
||||
elif is_video_sequence_header(tag):
|
||||
if tag == last_video_sequence_header:
|
||||
continue
|
||||
last_video_sequence_header = tag
|
||||
elif is_audio_sequence_header(tag):
|
||||
if tag == last_audio_sequence_header:
|
||||
continue
|
||||
last_audio_sequence_header = tag
|
||||
observer.on_next(tag)
|
||||
except EOFError:
|
||||
pass
|
||||
except Exception as e:
|
||||
observer.on_error(e)
|
||||
|
||||
def dispose() -> None:
|
||||
nonlocal disposed
|
||||
disposed = True
|
||||
reset()
|
||||
|
||||
subscription.disposable = source.subscribe(
|
||||
on_next, observer.on_error, observer.on_completed, scheduler=scheduler
|
||||
)
|
||||
|
||||
return CompositeDisposable(subscription, Disposable(dispose))
|
||||
|
||||
return Observable(subscribe)
|
||||
@@ -2,121 +2,72 @@ from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import Final, List, Optional, Union
|
||||
import os
|
||||
from typing import Optional, Union
|
||||
|
||||
import urllib3
|
||||
import av
|
||||
from reactivex import Observable, abc
|
||||
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
|
||||
from tenacity import Retrying, stop_after_delay, wait_fixed
|
||||
from tenacity.retry import retry_if_not_exception_type
|
||||
|
||||
from blrec.bili.live import Live
|
||||
from blrec.utils.io import wait_for
|
||||
|
||||
from ..stream_remuxer import StreamRemuxer
|
||||
from .segment_fetcher import InitSectionData, SegmentData
|
||||
|
||||
__all__ = ('SegmentRemuxer',)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.getLogger(urllib3.__name__).setLevel(logging.WARNING)
|
||||
|
||||
TRACE_REMUX_SEGMENT = bool(os.environ.get('TRACE_REMUX_SEGMENT'))
|
||||
TRACE_LIBAV = bool(os.environ.get('TRACE_LIBAV'))
|
||||
if TRACE_LIBAV:
|
||||
logging.getLogger('libav').setLevel(5)
|
||||
else:
|
||||
av.logging.set_level(av.logging.FATAL)
|
||||
|
||||
|
||||
class SegmentRemuxer:
|
||||
_SEGMENT_DATA_CACHE: Final = 10
|
||||
_MAX_SEGMENT_DATA_CACHE: Final = 15
|
||||
|
||||
def __init__(self, live: Live) -> None:
|
||||
self._live = live
|
||||
self._timeout: float = 10
|
||||
self._stream_remuxer = StreamRemuxer(live.room_id, remove_filler_data=True)
|
||||
|
||||
def __call__(
|
||||
self, source: Observable[Union[InitSectionData, SegmentData]]
|
||||
) -> Observable[io.RawIOBase]:
|
||||
) -> Observable[bytes]:
|
||||
return self._remux(source)
|
||||
|
||||
def _remux(
|
||||
self, source: Observable[Union[InitSectionData, SegmentData]]
|
||||
) -> Observable[io.RawIOBase]:
|
||||
) -> Observable[bytes]:
|
||||
def subscribe(
|
||||
observer: abc.ObserverBase[io.RawIOBase],
|
||||
observer: abc.ObserverBase[bytes],
|
||||
scheduler: Optional[abc.SchedulerBase] = None,
|
||||
) -> abc.DisposableBase:
|
||||
disposed = False
|
||||
subscription = SerialDisposable()
|
||||
|
||||
init_section_data: Optional[bytes] = None
|
||||
segment_data_cache: List[bytes] = []
|
||||
self._stream_remuxer.stop()
|
||||
|
||||
def reset() -> None:
|
||||
nonlocal init_section_data, segment_data_cache
|
||||
nonlocal init_section_data
|
||||
init_section_data = None
|
||||
segment_data_cache = []
|
||||
self._stream_remuxer.stop()
|
||||
|
||||
def write(data: bytes) -> int:
|
||||
return wait_for(
|
||||
self._stream_remuxer.input.write,
|
||||
args=(data,),
|
||||
timeout=self._timeout,
|
||||
)
|
||||
|
||||
def on_next(data: Union[InitSectionData, SegmentData]) -> None:
|
||||
nonlocal init_section_data
|
||||
nonlocal segment_data_cache
|
||||
|
||||
if isinstance(data, InitSectionData):
|
||||
init_section_data = data.payload
|
||||
segment_data_cache.clear()
|
||||
logger.debug('Stop stream remuxer for init section')
|
||||
self._stream_remuxer.stop()
|
||||
observer.on_next(b'')
|
||||
return
|
||||
|
||||
if self._stream_remuxer.exception and not self._stream_remuxer.stopped:
|
||||
logger.debug(
|
||||
'Stop stream remuxer due to '
|
||||
+ repr(self._stream_remuxer.exception)
|
||||
)
|
||||
self._stream_remuxer.stop()
|
||||
if init_section_data is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if self._stream_remuxer.stopped:
|
||||
self._stream_remuxer.start()
|
||||
while True:
|
||||
ready = self._stream_remuxer.wait(timeout=1)
|
||||
if disposed:
|
||||
return
|
||||
if ready:
|
||||
break
|
||||
|
||||
observer.on_next(RemuxedStream(self._stream_remuxer))
|
||||
|
||||
if init_section_data:
|
||||
write(init_section_data)
|
||||
if segment_data_cache:
|
||||
for cached_data in segment_data_cache:
|
||||
write(cached_data)
|
||||
if isinstance(data, InitSectionData):
|
||||
return
|
||||
|
||||
write(data.payload)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to write data to stream remuxer: {repr(e)}')
|
||||
logger.debug(f'Stop stream remuxer due to {repr(e)}')
|
||||
self._stream_remuxer.stop()
|
||||
if len(segment_data_cache) >= self._MAX_SEGMENT_DATA_CACHE:
|
||||
segment_data_cache = segment_data_cache[
|
||||
-self._MAX_SEGMENT_DATA_CACHE + 1 :
|
||||
]
|
||||
remuxed_data = self._remux_segemnt(init_section_data + data.payload)
|
||||
except av.FFmpegError as e:
|
||||
logger.warning(f'Failed to remux segment: {repr(e)}', exc_info=e)
|
||||
else:
|
||||
if len(segment_data_cache) >= self._SEGMENT_DATA_CACHE:
|
||||
segment_data_cache = segment_data_cache[
|
||||
-self._SEGMENT_DATA_CACHE + 1 :
|
||||
]
|
||||
|
||||
segment_data_cache.append(data.payload)
|
||||
observer.on_next(remuxed_data)
|
||||
|
||||
def dispose() -> None:
|
||||
nonlocal disposed
|
||||
@@ -131,56 +82,30 @@ class SegmentRemuxer:
|
||||
|
||||
return Observable(subscribe)
|
||||
|
||||
def _remux_segemnt(self, data: bytes, format: str = 'flv') -> bytes:
|
||||
in_file = io.BytesIO(data)
|
||||
out_file = io.BytesIO()
|
||||
|
||||
class CloseRemuxedStream(Exception):
|
||||
pass
|
||||
with av.open(in_file) as in_container:
|
||||
with av.open(out_file, mode='w', format=format) as out_container:
|
||||
in_video_stream = in_container.streams.video[0]
|
||||
in_audio_stream = in_container.streams.audio[0]
|
||||
out_video_stream = out_container.add_stream(template=in_video_stream)
|
||||
out_audio_stream = out_container.add_stream(template=in_audio_stream)
|
||||
|
||||
for packet in in_container.demux():
|
||||
if TRACE_REMUX_SEGMENT:
|
||||
logger.debug(repr(packet))
|
||||
# We need to skip the "flushing" packets that `demux` generates.
|
||||
if packet.dts is None:
|
||||
continue
|
||||
# We need to assign the packet to the new stream.
|
||||
if packet.stream.type == 'video':
|
||||
packet.stream = out_video_stream
|
||||
elif packet.stream.type == 'audio':
|
||||
packet.stream = out_audio_stream
|
||||
else:
|
||||
raise NotImplementedError(packet.stream.type)
|
||||
out_container.mux(packet)
|
||||
|
||||
class RemuxedStream(io.RawIOBase):
|
||||
def __init__(
|
||||
self, stream_remuxer: StreamRemuxer, *, read_timeout: float = 10
|
||||
) -> None:
|
||||
self._stream_remuxer = stream_remuxer
|
||||
self._read_timeout = read_timeout
|
||||
self._offset: int = 0
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
if self._stream_remuxer.stopped:
|
||||
ready = self._stream_remuxer.wait(timeout=self._read_timeout)
|
||||
if not ready:
|
||||
msg = f'Stream remuxer not ready in {self._read_timeout} seconds'
|
||||
logger.debug(msg)
|
||||
raise EOFError(msg)
|
||||
|
||||
try:
|
||||
for attempt in Retrying(
|
||||
reraise=True,
|
||||
retry=retry_if_not_exception_type(TimeoutError),
|
||||
wait=wait_fixed(1),
|
||||
stop=stop_after_delay(self._read_timeout),
|
||||
):
|
||||
with attempt:
|
||||
data = wait_for(
|
||||
self._stream_remuxer.output.read,
|
||||
args=(size,),
|
||||
timeout=self._read_timeout,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f'Failed to read data from stream remuxer: {repr(exc)}')
|
||||
self._stream_remuxer.exception = exc
|
||||
raise EOFError(exc)
|
||||
else:
|
||||
assert data is not None
|
||||
self._offset += len(data)
|
||||
return data
|
||||
|
||||
def tell(self) -> int:
|
||||
return self._offset
|
||||
|
||||
def close(self) -> None:
|
||||
if self._stream_remuxer.stopped:
|
||||
return
|
||||
if self._stream_remuxer.exception:
|
||||
return
|
||||
logger.debug('Close remuxed stream')
|
||||
self._stream_remuxer.exception = CloseRemuxedStream()
|
||||
return out_file.getvalue()
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import errno
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from contextlib import suppress
|
||||
from subprocess import PIPE, CalledProcessError, Popen
|
||||
from threading import Condition, Thread
|
||||
from typing import Optional, cast
|
||||
|
||||
from blrec.utils.io import wait_for
|
||||
from blrec.utils.mixins import StoppableMixin, SupportDebugMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
__all__ = ('StreamRemuxer',)
|
||||
|
||||
|
||||
class FFmpegError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class StreamRemuxer(StoppableMixin, SupportDebugMixin):
|
||||
_ERROR_PATTERN = re.compile(
|
||||
r'\b(error|failed|missing|invalid|corrupt)\b', re.IGNORECASE
|
||||
)
|
||||
|
||||
def __init__(self, room_id: int, remove_filler_data: bool = False) -> None:
|
||||
super().__init__()
|
||||
self._room_id = room_id
|
||||
self._remove_filler_data = remove_filler_data
|
||||
self._exception: Optional[Exception] = None
|
||||
self._ready = Condition()
|
||||
self._env = None
|
||||
|
||||
self._init_for_debug(room_id)
|
||||
if self._debug:
|
||||
self._env = os.environ.copy()
|
||||
path = os.path.join(self._debug_dir, f'ffreport-{room_id}-%t.log')
|
||||
self._env['FFREPORT'] = f'file={path}:level=48'
|
||||
|
||||
@property
|
||||
def input(self) -> io.BufferedWriter:
|
||||
assert self._subprocess.stdin is not None
|
||||
return cast(io.BufferedWriter, self._subprocess.stdin)
|
||||
|
||||
@property
|
||||
def output(self) -> io.BufferedReader:
|
||||
assert self._subprocess.stdout is not None
|
||||
return cast(io.BufferedReader, self._subprocess.stdout)
|
||||
|
||||
@property
|
||||
def exception(self) -> Optional[Exception]:
|
||||
return self._exception
|
||||
|
||||
@exception.setter
|
||||
def exception(self, exc: Exception) -> None:
|
||||
self._exception = exc
|
||||
|
||||
def __enter__(self): # type: ignore
|
||||
self.start()
|
||||
self.wait()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, value, traceback): # type: ignore
|
||||
self.stop()
|
||||
self.raise_for_exception()
|
||||
|
||||
def wait(self, timeout: Optional[float] = None) -> bool:
|
||||
with self._ready:
|
||||
return self._ready.wait(timeout=timeout)
|
||||
|
||||
def restart(self) -> None:
|
||||
logger.debug('Restarting stream remuxer...')
|
||||
self.stop()
|
||||
self.start()
|
||||
logger.debug('Restarted stream remuxer')
|
||||
|
||||
def raise_for_exception(self) -> None:
|
||||
if not self.exception:
|
||||
return
|
||||
raise self.exception
|
||||
|
||||
def _do_start(self) -> None:
|
||||
logger.debug('Starting stream remuxer...')
|
||||
self._thread = Thread(
|
||||
target=self._run, name=f'StreamRemuxer::{self._room_id}', daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def _do_stop(self) -> None:
|
||||
logger.debug('Stopping stream remuxer...')
|
||||
if hasattr(self, '_subprocess'):
|
||||
with suppress(ProcessLookupError):
|
||||
self._subprocess.kill()
|
||||
self._subprocess.wait(timeout=10)
|
||||
if hasattr(self, '_thread'):
|
||||
self._thread.join(timeout=10)
|
||||
|
||||
def _run(self) -> None:
|
||||
logger.debug('Started stream remuxer')
|
||||
self._exception = None
|
||||
try:
|
||||
self._run_subprocess()
|
||||
except BrokenPipeError as exc:
|
||||
logger.debug(repr(exc))
|
||||
except FFmpegError as exc:
|
||||
if not self._stopped:
|
||||
logger.warning(repr(exc))
|
||||
else:
|
||||
logger.debug(repr(exc))
|
||||
except TimeoutError as exc:
|
||||
logger.debug(repr(exc))
|
||||
except Exception as exc:
|
||||
# OSError: [Errno 22] Invalid argument
|
||||
# https://stackoverflow.com/questions/23688492/oserror-errno-22-invalid-argument-in-subprocess
|
||||
if isinstance(exc, OSError) and exc.errno == errno.EINVAL:
|
||||
pass
|
||||
else:
|
||||
self._exception = exc
|
||||
logger.exception(exc)
|
||||
finally:
|
||||
self._stopped = True
|
||||
logger.debug('Stopped stream remuxer')
|
||||
|
||||
def _run_subprocess(self) -> None:
|
||||
cmd = 'ffmpeg -xerror -i pipe:0 -c copy -copyts'
|
||||
if self._remove_filler_data:
|
||||
cmd += ' -bsf:v filter_units=remove_types=12'
|
||||
cmd += ' -f flv pipe:1'
|
||||
args = shlex.split(cmd)
|
||||
|
||||
with Popen(
|
||||
args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=self._env
|
||||
) as self._subprocess:
|
||||
with self._ready:
|
||||
self._ready.notify_all()
|
||||
|
||||
assert self._subprocess.stderr is not None
|
||||
with io.TextIOWrapper(
|
||||
self._subprocess.stderr, encoding='utf-8', errors='backslashreplace'
|
||||
) as stderr:
|
||||
while not self._stopped:
|
||||
line = wait_for(stderr.readline, timeout=10)
|
||||
if not line:
|
||||
if self._subprocess.poll() is not None:
|
||||
break
|
||||
else:
|
||||
continue
|
||||
if self._debug:
|
||||
logger.debug('ffmpeg: %s', line)
|
||||
self._check_error(line)
|
||||
|
||||
if not self._stopped and self._subprocess.returncode not in (0, 255):
|
||||
# 255: Exiting standardly, received signal 2.
|
||||
raise CalledProcessError(self._subprocess.returncode, cmd=cmd)
|
||||
|
||||
def _check_error(self, line: str) -> None:
|
||||
match = self._ERROR_PATTERN.search(line)
|
||||
if not match:
|
||||
return
|
||||
raise FFmpegError(line)
|
||||
@@ -1,19 +1,18 @@
|
||||
import os
|
||||
import logging
|
||||
from logging import LogRecord, Handler
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
import threading
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from logging import Handler, LogRecord
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from colorama import init, deinit, Fore, Back, Style
|
||||
from colorama import Back, Fore, Style, deinit, init
|
||||
from tqdm import tqdm
|
||||
|
||||
from .typing import LOG_LEVEL
|
||||
|
||||
|
||||
__all__ = 'configure_logger', 'ConsoleHandler', 'TqdmOutputStream'
|
||||
|
||||
|
||||
@@ -57,7 +56,7 @@ def obtain_room_id() -> str:
|
||||
name = task.get_name()
|
||||
|
||||
if '::' in name:
|
||||
if (room_id := name.split('::')[-1]):
|
||||
if room_id := name.split('::')[-1]:
|
||||
return room_id
|
||||
|
||||
return ''
|
||||
@@ -66,7 +65,7 @@ def obtain_room_id() -> str:
|
||||
def record_factory(*args: Any, **kwargs: Any) -> LogRecord:
|
||||
record = _old_factory(*args, **kwargs)
|
||||
|
||||
if (room_id := obtain_room_id()):
|
||||
if room_id := obtain_room_id():
|
||||
record.roomid = '[' + room_id + '] ' # type: ignore
|
||||
else:
|
||||
record.roomid = '' # type: ignore
|
||||
@@ -84,7 +83,6 @@ 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
|
||||
@@ -104,10 +102,10 @@ def configure_logger(
|
||||
|
||||
# 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,
|
||||
file_handler = TimedRotatingFileHandler(
|
||||
filename=log_file_path,
|
||||
when='MIDNIGHT',
|
||||
backupCount=backup_count or 0,
|
||||
encoding='utf-8',
|
||||
)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
|
||||
@@ -6,6 +6,7 @@ from .notifiers import (
|
||||
PushdeerNotifier,
|
||||
PushplusNotifier,
|
||||
TelegramNotifier,
|
||||
BarkNotifier,
|
||||
)
|
||||
from .providers import (
|
||||
MessagingProvider,
|
||||
@@ -14,6 +15,7 @@ from .providers import (
|
||||
Pushdeer,
|
||||
Pushplus,
|
||||
Telegram,
|
||||
Bark,
|
||||
)
|
||||
|
||||
|
||||
@@ -24,6 +26,7 @@ __all__ = (
|
||||
'Pushdeer',
|
||||
'Pushplus',
|
||||
'Telegram',
|
||||
'Bark',
|
||||
|
||||
'Notifier',
|
||||
'MessageNotifier',
|
||||
@@ -32,4 +35,5 @@ __all__ = (
|
||||
'PushdeerNotifier',
|
||||
'PushplusNotifier',
|
||||
'TelegramNotifier',
|
||||
'BarkNotifier',
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ from .providers import (
|
||||
Pushplus,
|
||||
Serverchan,
|
||||
Telegram,
|
||||
Bark,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
@@ -46,6 +47,7 @@ __all__ = (
|
||||
'PushdeerNotifier',
|
||||
'PushplusNotifier',
|
||||
'TelegramNotifier',
|
||||
'BarkNotifer',
|
||||
)
|
||||
|
||||
|
||||
@@ -403,3 +405,15 @@ class TelegramNotifier(MessageNotifier):
|
||||
|
||||
def _get_error_message_content(self, msg_type: Optional[MessageType] = None) -> str:
|
||||
return super()._get_error_message_content(msg_type='text')
|
||||
|
||||
|
||||
class BarkNotifier(MessageNotifier):
|
||||
provider = Bark.get_instance()
|
||||
|
||||
def _do_enable(self) -> None:
|
||||
super()._do_enable()
|
||||
logger.debug('Enabled Bark notifier')
|
||||
|
||||
def _do_disable(self) -> None:
|
||||
super()._do_disable()
|
||||
logger.debug('Disabled Bark notifier')
|
||||
|
||||
@@ -17,6 +17,7 @@ from ..setting.typing import (
|
||||
PushplusMessageType,
|
||||
ServerchanMessageType,
|
||||
TelegramMessageType,
|
||||
BarkMessageType,
|
||||
)
|
||||
from ..utils.patterns import Singleton
|
||||
|
||||
@@ -27,6 +28,7 @@ __all__ = (
|
||||
'Pushdeer',
|
||||
'Pushplus',
|
||||
'Telegram',
|
||||
'Bark',
|
||||
)
|
||||
|
||||
|
||||
@@ -216,10 +218,13 @@ class TelegramResponse(TypedDict):
|
||||
|
||||
|
||||
class Telegram(MessagingProvider):
|
||||
def __init__(self, token: str = '', chatid: str = '') -> None:
|
||||
_server: Final = 'https://api.telegram.org'
|
||||
|
||||
def __init__(self, token: str = '', chatid: str = '', server: str = '') -> None:
|
||||
super().__init__()
|
||||
self.token = token
|
||||
self.chatid = chatid
|
||||
self.server = server
|
||||
|
||||
async def send_message(
|
||||
self, title: str, content: str, msg_type: MessageType
|
||||
@@ -236,11 +241,12 @@ class Telegram(MessagingProvider):
|
||||
async def _post_message(
|
||||
self, title: str, content: str, msg_type: TelegramMessageType
|
||||
) -> None:
|
||||
url = f'https://api.telegram.org/bot{self.token}/sendMessage'
|
||||
url = urljoin(self.server or self._server, f'/bot{self.token}/sendMessage')
|
||||
payload = {
|
||||
'chat_id': self.chatid,
|
||||
'text': title + '\n\n' + content,
|
||||
'parse_mode': 'MarkdownV2' if msg_type == 'markdown' else 'HTML',
|
||||
'disable_web_page_preview': True,
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession(raise_for_status=True) as session:
|
||||
@@ -251,3 +257,50 @@ class Telegram(MessagingProvider):
|
||||
response['result']['error_code'],
|
||||
response['result']['description'],
|
||||
)
|
||||
|
||||
|
||||
class BarkResponse(TypedDict):
|
||||
code: int
|
||||
message: str
|
||||
timestamp: int
|
||||
|
||||
|
||||
class Bark(MessagingProvider):
|
||||
_server: Final = 'https://api.day.app'
|
||||
_endpoint: Final = '/push'
|
||||
|
||||
def __init__(self, server: str = '', pushkey: str = '') -> None:
|
||||
super().__init__()
|
||||
self.server = server
|
||||
self.pushkey = pushkey
|
||||
|
||||
async def send_message(
|
||||
self, title: str, content: str, msg_type: MessageType
|
||||
) -> None:
|
||||
self._check_parameters()
|
||||
await self._post_message(title, content, cast(BarkMessageType, msg_type))
|
||||
|
||||
def _check_parameters(self) -> None:
|
||||
if not self.pushkey:
|
||||
raise ValueError('No pushkey supplied')
|
||||
|
||||
async def _post_message(
|
||||
self, title: str, content: str, msg_type: BarkMessageType
|
||||
) -> None:
|
||||
url = urljoin(self.server or self._server, self._endpoint)
|
||||
# content size is limited to a maximum size of 4 KB (4096 bytes)
|
||||
if len(content.encode()) >= 4096:
|
||||
content = content.encode()[:4090].decode(errors='ignore') + ' ...'
|
||||
payload = {
|
||||
"title": title,
|
||||
"body": content,
|
||||
"device_key": self.pushkey,
|
||||
"badge": 1,
|
||||
"icon": "https://raw.githubusercontent.com/acgnhiki/blrec/master/webapp/src/assets/icons/icon-72x72.png",
|
||||
"group": "blrec",
|
||||
}
|
||||
async with aiohttp.ClientSession(raise_for_status=True) as session:
|
||||
async with session.post(url, json=payload) as res:
|
||||
response = cast(BarkResponse, await res.json())
|
||||
if response['code'] != 200:
|
||||
raise HTTPException(response['message'])
|
||||
|
||||
@@ -148,6 +148,8 @@ class Postprocessor(
|
||||
if video_path.endswith('.flv'):
|
||||
if not await self._is_vaild_flv_file(video_path):
|
||||
logger.warning(f'The flv file may be invalid: {video_path}')
|
||||
if os.path.getsize(video_path) < 1024**2:
|
||||
continue
|
||||
if self.remux_to_mp4:
|
||||
self._status = PostprocessorStatus.REMUXING
|
||||
(
|
||||
@@ -155,9 +157,6 @@ class Postprocessor(
|
||||
remuxing_result,
|
||||
) = await self._remux_video_to_mp4(video_path)
|
||||
if not self._debug:
|
||||
await discard_file(
|
||||
extra_metadata_path(video_path), 'DEBUG'
|
||||
)
|
||||
if self._should_delete_source_files(remuxing_result):
|
||||
await discard_file(video_path)
|
||||
elif self.inject_extra_metadata:
|
||||
@@ -165,6 +164,8 @@ class Postprocessor(
|
||||
result_path = await self._inject_extra_metadata(video_path)
|
||||
else:
|
||||
result_path = video_path
|
||||
if not self._debug:
|
||||
await discard_file(extra_metadata_path(video_path), 'DEBUG')
|
||||
|
||||
elif video_path.endswith('.m3u8'):
|
||||
if self.remux_to_mp4:
|
||||
@@ -176,7 +177,10 @@ class Postprocessor(
|
||||
await copy_files_related(video_path)
|
||||
if result_path != video_path:
|
||||
self._completed_files.append(danmaku_path(result_path))
|
||||
self._completed_files.remove(danmaku_path(video_path))
|
||||
with suppress(ValueError):
|
||||
self._completed_files.remove(
|
||||
danmaku_path(video_path)
|
||||
)
|
||||
if not self._debug:
|
||||
if self._should_delete_source_files(remuxing_result):
|
||||
await discard_dir(os.path.dirname(video_path))
|
||||
|
||||
@@ -36,6 +36,9 @@ from .models import (
|
||||
TelegramMessageTemplateSettings,
|
||||
TelegramNotificationSettings,
|
||||
TelegramSettings,
|
||||
BarkMessageTemplateSettings,
|
||||
BarkNotificationSettings,
|
||||
BarkSettings,
|
||||
WebHookSettings,
|
||||
)
|
||||
from .setting_manager import SettingsManager
|
||||
@@ -65,11 +68,13 @@ __all__ = (
|
||||
'PushdeerMessageTemplateSettings',
|
||||
'PushplusMessageTemplateSettings',
|
||||
'TelegramMessageTemplateSettings',
|
||||
'BarkMessageTemplateSettings',
|
||||
'EmailSettings',
|
||||
'ServerchanSettings',
|
||||
'PushdeerSettings',
|
||||
'PushplusSettings',
|
||||
'TelegramSettings',
|
||||
'BarkSettings',
|
||||
'NotifierSettings',
|
||||
'NotificationSettings',
|
||||
'EmailNotificationSettings',
|
||||
@@ -77,6 +82,7 @@ __all__ = (
|
||||
'PushdeerNotificationSettings',
|
||||
'PushplusNotificationSettings',
|
||||
'TelegramNotificationSettings',
|
||||
'BarkNotificationSettings',
|
||||
'WebHookSettings',
|
||||
'update_settings',
|
||||
'shadow_settings',
|
||||
|
||||
@@ -24,6 +24,7 @@ from .typing import (
|
||||
RecordingMode,
|
||||
ServerchanMessageType,
|
||||
TelegramMessageType,
|
||||
BarkMessageType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -54,6 +55,7 @@ __all__ = (
|
||||
'PushdeerSettings',
|
||||
'PushplusSettings',
|
||||
'TelegramSettings',
|
||||
'BarkSettings'
|
||||
'NotifierSettings',
|
||||
'NotificationSettings',
|
||||
'EmailMessageTemplateSettings',
|
||||
@@ -61,11 +63,13 @@ __all__ = (
|
||||
'PushdeerMessageTemplateSettings',
|
||||
'PushplusMessageTemplateSettings',
|
||||
'TelegramMessageTemplateSettings',
|
||||
'BarkMessageTemplateSettings',
|
||||
'EmailNotificationSettings',
|
||||
'ServerchanNotificationSettings',
|
||||
'PushdeerNotificationSettings',
|
||||
'PushplusNotificationSettings',
|
||||
'TelegramNotificationSettings',
|
||||
'BarkNotificationSettings',
|
||||
'WebHookSettings',
|
||||
)
|
||||
|
||||
@@ -322,12 +326,7 @@ def log_dir_factory() -> str:
|
||||
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
|
||||
backup_count: Annotated[int, Field(ge=0, le=90)] = 30
|
||||
|
||||
@validator('log_dir')
|
||||
def _validate_dir(cls, path: str) -> str:
|
||||
@@ -405,6 +404,7 @@ class PushplusSettings(BaseModel):
|
||||
class TelegramSettings(BaseModel):
|
||||
token: str = ''
|
||||
chatid: str = ''
|
||||
server: str = ''
|
||||
|
||||
@validator('token')
|
||||
def _validate_token(cls, value: str) -> str:
|
||||
@@ -418,6 +418,28 @@ class TelegramSettings(BaseModel):
|
||||
raise ValueError('chatid is invalid')
|
||||
return value
|
||||
|
||||
@validator('server')
|
||||
def _validate_server(cls, value: str) -> str:
|
||||
if value != '' and not re.fullmatch(r'^https?:\/\/[a-zA-Z0-9-_.]+(:[0-9]+)?', value):
|
||||
raise ValueError('server is invalid')
|
||||
return value
|
||||
|
||||
class BarkSettings(BaseModel):
|
||||
server: str = ''
|
||||
pushkey: str = ''
|
||||
|
||||
@validator('server')
|
||||
def _validate_server(cls, value: str) -> str:
|
||||
if value != '' and not re.fullmatch(r'https?://.+', value):
|
||||
raise ValueError('server is invalid')
|
||||
return value
|
||||
|
||||
@validator('pushkey')
|
||||
def _validate_pushkey(cls, value: str) -> str:
|
||||
if value != '' and not re.fullmatch(r'[a-zA-Z\d]+', value):
|
||||
raise ValueError('pushkey is invalid')
|
||||
return value
|
||||
|
||||
|
||||
class NotifierSettings(BaseModel):
|
||||
enabled: bool = False
|
||||
@@ -520,6 +542,21 @@ class TelegramMessageTemplateSettings(MessageTemplateSettings):
|
||||
error_message_content: str = ''
|
||||
|
||||
|
||||
class BarkMessageTemplateSettings(MessageTemplateSettings):
|
||||
began_message_type: BarkMessageType = 'markdown'
|
||||
began_message_title: str = ''
|
||||
began_message_content: str = ''
|
||||
ended_message_type: BarkMessageType = 'markdown'
|
||||
ended_message_title: str = ''
|
||||
ended_message_content: str = ''
|
||||
space_message_type: BarkMessageType = 'markdown'
|
||||
space_message_title: str = ''
|
||||
space_message_content: str = ''
|
||||
error_message_type: BarkMessageType = 'markdown'
|
||||
error_message_title: str = ''
|
||||
error_message_content: str = ''
|
||||
|
||||
|
||||
class EmailNotificationSettings(
|
||||
EmailSettings, NotifierSettings, NotificationSettings, EmailMessageTemplateSettings
|
||||
):
|
||||
@@ -562,6 +599,15 @@ class TelegramNotificationSettings(
|
||||
pass
|
||||
|
||||
|
||||
class BarkNotificationSettings(
|
||||
BarkSettings,
|
||||
NotifierSettings,
|
||||
NotificationSettings,
|
||||
BarkMessageTemplateSettings,
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
class WebHookEventSettings(BaseModel):
|
||||
live_began: bool = True
|
||||
live_ended: bool = True
|
||||
@@ -607,6 +653,7 @@ class Settings(BaseModel):
|
||||
pushdeer_notification: PushdeerNotificationSettings = PushdeerNotificationSettings()
|
||||
pushplus_notification: PushplusNotificationSettings = PushplusNotificationSettings()
|
||||
telegram_notification: TelegramNotificationSettings = TelegramNotificationSettings()
|
||||
bark_notification: BarkNotificationSettings = BarkNotificationSettings()
|
||||
webhooks: Annotated[List[WebHookSettings], Field(max_items=50)] = []
|
||||
|
||||
@classmethod
|
||||
@@ -655,6 +702,7 @@ class SettingsIn(BaseModel):
|
||||
pushdeer_notification: Optional[PushdeerNotificationSettings] = None
|
||||
pushplus_notification: Optional[PushplusNotificationSettings] = None
|
||||
telegram_notification: Optional[TelegramNotificationSettings] = None
|
||||
bark_notification: Optional[BarkNotificationSettings] = None
|
||||
webhooks: Optional[List[WebHookSettings]] = None
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from ..notification import (
|
||||
Pushplus,
|
||||
Serverchan,
|
||||
Telegram,
|
||||
Bark,
|
||||
)
|
||||
from ..webhook import WebHook
|
||||
from .helpers import shadow_settings, update_settings
|
||||
@@ -264,7 +265,6 @@ class SettingsManager:
|
||||
configure_logger(
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -345,6 +345,14 @@ class SettingsManager:
|
||||
self._apply_notification_settings(notifier, settings)
|
||||
self._apply_message_template_settings(notifier, settings)
|
||||
|
||||
def apply_bark_notification_settings(self) -> None:
|
||||
notifier = self._app._bark_notifier
|
||||
settings = self._settings.bark_notification
|
||||
self._apply_bark_settings(notifier.provider)
|
||||
self._apply_notifier_settings(notifier, settings)
|
||||
self._apply_notification_settings(notifier, settings)
|
||||
self._apply_message_template_settings(notifier, settings)
|
||||
|
||||
def apply_webhooks_settings(self) -> None:
|
||||
webhooks = [WebHook.from_settings(s) for s in self._settings.webhooks]
|
||||
self._app._webhook_emitter.webhooks = webhooks
|
||||
@@ -370,6 +378,11 @@ class SettingsManager:
|
||||
def _apply_telegram_settings(self, telegram: Telegram) -> None:
|
||||
telegram.token = self._settings.telegram_notification.token
|
||||
telegram.chatid = self._settings.telegram_notification.chatid
|
||||
telegram.server = self._settings.telegram_notification.server
|
||||
|
||||
def _apply_bark_settings(self, bark: Bark) -> None:
|
||||
bark.server = self._settings.bark_notification.server
|
||||
bark.pushkey = self._settings.bark_notification.pushkey
|
||||
|
||||
def _apply_notifier_settings(
|
||||
self, notifier: Notifier, settings: NotifierSettings
|
||||
|
||||
@@ -12,6 +12,7 @@ ServerchanMessageType = MarkdownMessageType
|
||||
PushdeerMessageType = Union[TextMessageType, MarkdownMessageType]
|
||||
PushplusMessageType = Union[TextMessageType, MarkdownMessageType, HtmlMessageType]
|
||||
TelegramMessageType = Union[MarkdownMessageType, HtmlMessageType]
|
||||
BarkMessageType = Union[TextMessageType, MarkdownMessageType]
|
||||
|
||||
|
||||
KeyOfSettings = Literal[
|
||||
@@ -30,6 +31,7 @@ KeyOfSettings = Literal[
|
||||
'pushdeer_notification',
|
||||
'pushplus_notification',
|
||||
'telegram_notification',
|
||||
'bark_notification',
|
||||
'webhooks',
|
||||
]
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ from typing import TYPE_CHECKING, Dict, Iterator, Optional
|
||||
import aiohttp
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_delay, wait_exponential
|
||||
|
||||
from blrec.utils.libc import malloc_trim
|
||||
|
||||
from ..bili.exceptions import ApiRequestError
|
||||
from ..exception import NotFoundError, submit_exception
|
||||
from ..flv.operators import MetaData, StreamProfile
|
||||
@@ -17,8 +19,8 @@ if TYPE_CHECKING:
|
||||
from ..setting import SettingsManager
|
||||
|
||||
from ..setting import (
|
||||
DanmakuSettings,
|
||||
BiliApiSettings,
|
||||
DanmakuSettings,
|
||||
HeaderSettings,
|
||||
OutputSettings,
|
||||
PostprocessingSettings,
|
||||
@@ -52,12 +54,14 @@ class RecordTaskManager:
|
||||
logger.info('Load all tasks complete')
|
||||
|
||||
async def destroy_all_tasks(self) -> None:
|
||||
logger.info('Destroying all tasks...')
|
||||
if not self._tasks:
|
||||
return
|
||||
await asyncio.wait([t.destroy() for t in self._tasks.values() if t.ready])
|
||||
logger.debug('Destroying all tasks...')
|
||||
for task in self._tasks.values():
|
||||
if not task.ready:
|
||||
continue
|
||||
await task.destroy()
|
||||
self._tasks.clear()
|
||||
logger.info('Successfully destroyed all task')
|
||||
malloc_trim(0)
|
||||
logger.debug('Successfully destroyed all task')
|
||||
|
||||
def has_task(self, room_id: int) -> bool:
|
||||
return room_id in self._tasks
|
||||
@@ -110,72 +114,110 @@ class RecordTaskManager:
|
||||
logger.info(f'Successfully added task {settings.room_id}')
|
||||
|
||||
async def remove_task(self, room_id: int) -> None:
|
||||
logger.debug(f'Removing task {room_id}...')
|
||||
task = self._get_task(room_id, check_ready=True)
|
||||
await task.disable_recorder(force=True)
|
||||
await task.disable_monitor()
|
||||
await task.destroy()
|
||||
del self._tasks[room_id]
|
||||
malloc_trim(0)
|
||||
logger.debug(f'Removed task {room_id}')
|
||||
|
||||
async def remove_all_tasks(self) -> None:
|
||||
coros = [self.remove_task(i) for i, t in self._tasks.items() if t.ready]
|
||||
if coros:
|
||||
await asyncio.wait(coros)
|
||||
logger.debug('Removing all tasks...')
|
||||
for room_id, task in self._tasks.items():
|
||||
if not task.ready:
|
||||
continue
|
||||
await self.remove_task(room_id)
|
||||
malloc_trim(0)
|
||||
logger.debug('Removed all tasks')
|
||||
|
||||
async def start_task(self, room_id: int) -> None:
|
||||
logger.debug(f'Starting task {room_id}...')
|
||||
task = self._get_task(room_id, check_ready=True)
|
||||
await task.update_info()
|
||||
await task.enable_monitor()
|
||||
await task.enable_recorder()
|
||||
logger.debug(f'Started task {room_id}')
|
||||
|
||||
async def stop_task(self, room_id: int, force: bool = False) -> None:
|
||||
logger.debug(f'Stopping task {room_id}...')
|
||||
task = self._get_task(room_id, check_ready=True)
|
||||
await task.disable_recorder(force)
|
||||
await task.disable_monitor()
|
||||
logger.debug(f'Stopped task {room_id}')
|
||||
|
||||
async def start_all_tasks(self) -> None:
|
||||
await self.update_all_task_infos()
|
||||
await self.enable_all_task_monitors()
|
||||
await self.enable_all_task_recorders()
|
||||
logger.debug('Starting all tasks...')
|
||||
for room_id, task in self._tasks.items():
|
||||
if not task.ready:
|
||||
continue
|
||||
await self.start_task(room_id)
|
||||
logger.debug('Started all tasks')
|
||||
|
||||
async def stop_all_tasks(self, force: bool = False) -> None:
|
||||
await self.disable_all_task_recorders(force)
|
||||
await self.disable_all_task_monitors()
|
||||
logger.debug('Stopping all tasks...')
|
||||
for room_id, task in self._tasks.items():
|
||||
if not task.ready:
|
||||
continue
|
||||
await self.stop_task(room_id, force=force)
|
||||
logger.debug('Stopped all tasks')
|
||||
|
||||
async def enable_task_monitor(self, room_id: int) -> None:
|
||||
logger.debug(f'Enabling live monitor for task {room_id}...')
|
||||
task = self._get_task(room_id, check_ready=True)
|
||||
await task.enable_monitor()
|
||||
logger.debug(f'Enabled live monitor for task {room_id}')
|
||||
|
||||
async def disable_task_monitor(self, room_id: int) -> None:
|
||||
logger.debug(f'Disabling live monitor for task {room_id}...')
|
||||
task = self._get_task(room_id, check_ready=True)
|
||||
await task.disable_monitor()
|
||||
logger.debug(f'Disabled live monitor for task {room_id}')
|
||||
|
||||
async def enable_all_task_monitors(self) -> None:
|
||||
coros = [t.enable_monitor() for t in self._tasks.values() if t.ready]
|
||||
if coros:
|
||||
await asyncio.wait(coros)
|
||||
logger.debug('Enabling live monitor for all tasks...')
|
||||
for room_id, task in self._tasks.items():
|
||||
if not task.ready:
|
||||
continue
|
||||
await self.enable_task_monitor(room_id)
|
||||
logger.debug('Enabled live monitor for all tasks')
|
||||
|
||||
async def disable_all_task_monitors(self) -> None:
|
||||
coros = [t.disable_monitor() for t in self._tasks.values() if t.ready]
|
||||
if coros:
|
||||
await asyncio.wait(coros)
|
||||
logger.debug('Disabling live monitor for all tasks...')
|
||||
for room_id, task in self._tasks.items():
|
||||
if not task.ready:
|
||||
continue
|
||||
await self.disable_task_monitor(room_id)
|
||||
logger.debug('Disabled live monitor for all tasks')
|
||||
|
||||
async def enable_task_recorder(self, room_id: int) -> None:
|
||||
logger.debug(f'Enabling recorder for task {room_id}...')
|
||||
task = self._get_task(room_id, check_ready=True)
|
||||
await task.enable_recorder()
|
||||
logger.debug(f'Enabled recorder for task {room_id}')
|
||||
|
||||
async def disable_task_recorder(self, room_id: int, force: bool = False) -> None:
|
||||
logger.debug(f'Disabling recorder for task {room_id}...')
|
||||
task = self._get_task(room_id, check_ready=True)
|
||||
await task.disable_recorder(force)
|
||||
logger.debug(f'Disabled recorder for task {room_id}')
|
||||
|
||||
async def enable_all_task_recorders(self) -> None:
|
||||
coros = [t.enable_recorder() for t in self._tasks.values() if t.ready]
|
||||
if coros:
|
||||
await asyncio.wait(coros)
|
||||
logger.debug('Enabling recorder for all tasks...')
|
||||
for room_id, task in self._tasks.items():
|
||||
if not task.ready:
|
||||
continue
|
||||
await self.enable_task_recorder(room_id)
|
||||
logger.debug('Enabled recorder for all tasks')
|
||||
|
||||
async def disable_all_task_recorders(self, force: bool = False) -> None:
|
||||
coros = [t.disable_recorder(force) for t in self._tasks.values() if t.ready]
|
||||
if coros:
|
||||
await asyncio.wait(coros)
|
||||
logger.debug('Disabling recorder for all tasks...')
|
||||
for room_id, task in self._tasks.items():
|
||||
if not task.ready:
|
||||
continue
|
||||
await self.disable_task_recorder(room_id, force=force)
|
||||
logger.debug('Disabled recorder for all tasks')
|
||||
|
||||
def get_task_data(self, room_id: int) -> TaskData:
|
||||
task = self._get_task(room_id, check_ready=True)
|
||||
@@ -216,15 +258,18 @@ class RecordTaskManager:
|
||||
return task.cut_stream()
|
||||
|
||||
async def update_task_info(self, room_id: int) -> None:
|
||||
logger.debug(f'Updating info for task {room_id}...')
|
||||
task = self._get_task(room_id, check_ready=True)
|
||||
await task.update_info(raise_exception=True)
|
||||
logger.debug(f'Updated info for task {room_id}')
|
||||
|
||||
async def update_all_task_infos(self) -> None:
|
||||
coros = [
|
||||
t.update_info(raise_exception=True) for t in self._tasks.values() if t.ready
|
||||
]
|
||||
if coros:
|
||||
await asyncio.wait(coros)
|
||||
logger.debug('Updating info for all tasks...')
|
||||
for room_id, task in self._tasks.items():
|
||||
if not task.ready:
|
||||
continue
|
||||
await self.update_task_info(room_id)
|
||||
logger.debug('Updated info for all tasks')
|
||||
|
||||
def apply_task_bili_api_settings(
|
||||
self, room_id: int, settings: BiliApiSettings
|
||||
|
||||
16
src/blrec/utils/libc.py
Normal file
16
src/blrec/utils/libc.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from ctypes import cdll
|
||||
from ctypes.util import find_library
|
||||
|
||||
lib_name = find_library('c')
|
||||
if not lib_name:
|
||||
libc = None
|
||||
else:
|
||||
libc = cdll.LoadLibrary(lib_name)
|
||||
|
||||
|
||||
def malloc_trim(pad: int) -> bool:
|
||||
"""Release free memory from the heap"""
|
||||
assert pad >= 0, 'pad must be >= 0'
|
||||
if libc is None:
|
||||
return False
|
||||
return libc.malloc_trim(pad) == 1
|
||||
@@ -1,4 +1,5 @@
|
||||
from .replace import replace
|
||||
from .retry import retry
|
||||
from .observe_on import observe_on_new_thread
|
||||
|
||||
__all__ = ('replace', 'retry')
|
||||
__all__ = ('replace', 'retry', 'observe_on_new_thread')
|
||||
|
||||
54
src/blrec/utils/operators/observe_on.py
Normal file
54
src/blrec/utils/operators/observe_on.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from queue import Queue
|
||||
from threading import Thread
|
||||
from typing import Any, Callable, Optional, TypeVar
|
||||
|
||||
from reactivex import Observable, abc
|
||||
from reactivex.disposable import CompositeDisposable, Disposable, SerialDisposable
|
||||
|
||||
|
||||
_T = TypeVar('_T')
|
||||
|
||||
|
||||
def observe_on_new_thread(
|
||||
queue_size: Optional[int] = None, thread_name: Optional[str] = None
|
||||
) -> Callable[[Observable[_T]], Observable[_T]]:
|
||||
def observe_on(source: Observable[_T]) -> Observable[_T]:
|
||||
def subscribe(
|
||||
observer: abc.ObserverBase[_T],
|
||||
scheduler: Optional[abc.SchedulerBase] = None,
|
||||
) -> abc.DisposableBase:
|
||||
disposed = False
|
||||
subscription = SerialDisposable()
|
||||
queue: Queue[Callable[..., Any]] = Queue(maxsize=queue_size or 0)
|
||||
|
||||
def run() -> None:
|
||||
while not disposed:
|
||||
queue.get()()
|
||||
|
||||
thread = Thread(target=run, name=thread_name, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def on_next(value: _T) -> None:
|
||||
queue.put(lambda: observer.on_next(value))
|
||||
|
||||
def on_error(exc: Exception) -> None:
|
||||
queue.put(lambda: observer.on_error(exc))
|
||||
|
||||
def on_completed() -> None:
|
||||
queue.put(lambda: observer.on_completed)
|
||||
|
||||
def dispose() -> None:
|
||||
nonlocal disposed
|
||||
disposed = True
|
||||
queue.put(lambda: None)
|
||||
thread.join()
|
||||
|
||||
subscription.disposable = source.subscribe(
|
||||
on_next, on_error, on_completed, scheduler=scheduler
|
||||
)
|
||||
|
||||
return CompositeDisposable(subscription, Disposable(dispose))
|
||||
|
||||
return Observable(subscribe)
|
||||
|
||||
return observe_on
|
||||
@@ -137,12 +137,12 @@ api.include_router(update.router)
|
||||
|
||||
|
||||
class WebAppFiles(StaticFiles):
|
||||
async def lookup_path(
|
||||
def lookup_path(
|
||||
self, path: str
|
||||
) -> Tuple[str, Optional[os.stat_result]]:
|
||||
if path == '404.html':
|
||||
path = 'index.html'
|
||||
return await super().lookup_path(path)
|
||||
return super().lookup_path(path)
|
||||
|
||||
def file_response(self, full_path: str, *args, **kwargs) -> Response: # type: ignore # noqa
|
||||
# ignore MIME types from Windows registry
|
||||
|
||||
@@ -55,5 +55,6 @@ AliasKeyOfSettings = Literal[
|
||||
'pushdeerNotification',
|
||||
'pushplusNotification',
|
||||
'telegramNotification',
|
||||
'barkNotification',
|
||||
'webhooks',
|
||||
]
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Optional, Set, Dict
|
||||
from typing import Dict, Optional, Set
|
||||
|
||||
from fastapi import status, Request, Header
|
||||
from fastapi import Header, Request, status
|
||||
from fastapi.exceptions import HTTPException
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -21,34 +20,26 @@ attempting_clients: Dict[str, int] = {}
|
||||
|
||||
|
||||
async def authenticate(
|
||||
request: Request,
|
||||
x_api_key: Optional[str] = Header(None),
|
||||
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',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail='No api key'
|
||||
)
|
||||
|
||||
assert request.client is not None, 'client should not be None'
|
||||
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',
|
||||
)
|
||||
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
|
||||
):
|
||||
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',
|
||||
detail='Max clients allowed in whitelist or blacklist ' 'will exceeded',
|
||||
)
|
||||
if len(attempting_clients) >= MAX_ATTEMPTING_CLIENTS:
|
||||
raise HTTPException(
|
||||
@@ -71,8 +62,7 @@ async def authenticate(
|
||||
if client_ip in whitelist:
|
||||
whitelist.remove(client_ip)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='API key is invalid',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail='API key is invalid'
|
||||
)
|
||||
|
||||
if client_ip in attempting_clients:
|
||||
|
||||
@@ -36,19 +36,6 @@
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label class="setting-label" nzNoColon
|
||||
>日志文件分割大小</nz-form-label
|
||||
>
|
||||
<nz-form-control
|
||||
class="setting-control select"
|
||||
[nzWarningTip]="syncFailedWarningTip"
|
||||
[nzValidateStatus]="syncStatus.maxBytes ? maxBytesControl : 'warning'"
|
||||
>
|
||||
<nz-select formControlName="maxBytes" [nzOptions]="maxBytesOptions">
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label class="setting-label" nzNoColon
|
||||
>日志文件备份数量</nz-form-label
|
||||
|
||||
@@ -42,12 +42,7 @@ export class LoggingSettingsComponent implements OnInit, OnChanges {
|
||||
{ label: 'CRITICAL', value: 'CRITICAL' },
|
||||
];
|
||||
|
||||
readonly maxBytesOptions = range(1, 11).map((i) => ({
|
||||
label: `${i} MB`,
|
||||
value: 1024 ** 2 * i,
|
||||
}));
|
||||
|
||||
readonly backupOptions = range(1, 31).map((i) => ({
|
||||
readonly backupOptions = range(0, 91).map((i) => ({
|
||||
label: i.toString(),
|
||||
value: i,
|
||||
}));
|
||||
@@ -60,7 +55,6 @@ export class LoggingSettingsComponent implements OnInit, OnChanges {
|
||||
this.settingsForm = formBuilder.group({
|
||||
logDir: [''],
|
||||
consoleLogLevel: [''],
|
||||
maxBytes: [''],
|
||||
backupCount: [''],
|
||||
});
|
||||
}
|
||||
@@ -73,10 +67,6 @@ export class LoggingSettingsComponent implements OnInit, OnChanges {
|
||||
return this.settingsForm.get('consoleLogLevel') as FormControl;
|
||||
}
|
||||
|
||||
get maxBytesControl() {
|
||||
return this.settingsForm.get('maxBytes') as FormControl;
|
||||
}
|
||||
|
||||
get backupCountControl() {
|
||||
return this.settingsForm.get('backupCount') as FormControl;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<app-sub-page pageTitle="Bark 通知">
|
||||
<ng-template appSubPageContent>
|
||||
<app-page-section>
|
||||
<app-notifier-settings [settings]="notifierSettings" keyOfSettings="barkNotification"></app-notifier-settings>
|
||||
</app-page-section>
|
||||
|
||||
<app-page-section name="Bark">
|
||||
<app-bark-settings [settings]="barkSettings"></app-bark-settings>
|
||||
</app-page-section>
|
||||
|
||||
<app-page-section name="事件">
|
||||
<app-event-settings [settings]="notificationSettings" keyOfSettings="barkNotification"></app-event-settings>
|
||||
</app-page-section>
|
||||
|
||||
<app-page-section name="消息">
|
||||
<app-message-template-settings [settings]="messageTemplateSettings" keyOfSettings="barkNotification">
|
||||
</app-message-template-settings>
|
||||
</app-page-section>
|
||||
</ng-template>
|
||||
</app-sub-page>
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
@use '../../shared/styles/setting';
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { BarkNotificationSettingsComponent } from './bark-notification-settings.component';
|
||||
|
||||
describe('BarkNotificationSettingsComponent', () => {
|
||||
let component: BarkNotificationSettingsComponent;
|
||||
let fixture: ComponentFixture<BarkNotificationSettingsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [BarkNotificationSettingsComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BarkNotificationSettingsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import pick from 'lodash-es/pick';
|
||||
|
||||
import {
|
||||
KEYS_OF_MESSAGE_TEMPLATE_SETTINGS,
|
||||
KEYS_OF_NOTIFICATION_SETTINGS,
|
||||
KEYS_OF_NOTIFIER_SETTINGS,
|
||||
KEYS_OF_BARK_SETTINGS,
|
||||
MessageTemplateSettings,
|
||||
NotificationSettings,
|
||||
NotifierSettings,
|
||||
BarkNotificationSettings,
|
||||
BarkSettings,
|
||||
} from '../../shared/setting.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bark-notification-settings',
|
||||
templateUrl: './bark-notification-settings.component.html',
|
||||
styleUrls: ['./bark-notification-settings.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BarkNotificationSettingsComponent implements OnInit {
|
||||
barkSettings!: BarkSettings;
|
||||
notifierSettings!: NotifierSettings;
|
||||
notificationSettings!: NotificationSettings;
|
||||
messageTemplateSettings!: MessageTemplateSettings;
|
||||
|
||||
constructor(
|
||||
private changeDetector: ChangeDetectorRef,
|
||||
private route: ActivatedRoute
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.data.subscribe((data) => {
|
||||
const settings = data.settings as BarkNotificationSettings;
|
||||
this.barkSettings = pick(settings, KEYS_OF_BARK_SETTINGS);
|
||||
this.notifierSettings = pick(settings, KEYS_OF_NOTIFIER_SETTINGS);
|
||||
this.notificationSettings = pick(settings, KEYS_OF_NOTIFICATION_SETTINGS);
|
||||
this.messageTemplateSettings = pick(
|
||||
settings,
|
||||
KEYS_OF_MESSAGE_TEMPLATE_SETTINGS
|
||||
);
|
||||
this.changeDetector.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<form nz-form [formGroup]="settingsForm">
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label class="setting-label align-required" nzFor="server" nzNoColon>server</nz-form-label>
|
||||
<nz-form-control class="setting-control input" nzHasFeedback [nzErrorTip]="serverErrorTip"
|
||||
[nzWarningTip]="syncFailedWarningTip" [nzValidateStatus]="
|
||||
serverControl.valid && !syncStatus.server ? 'warning' : serverControl
|
||||
">
|
||||
<input id="server" type="url" placeholder="默认为官方服务器 https://api.day.app" nz-input formControlName="server" />
|
||||
<ng-template #serverErrorTip let-control>
|
||||
<ng-container *ngIf="control.hasError('pattern')">
|
||||
server 无效
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label class="setting-label" nzFor="pushkey" nzNoColon nzRequired>pushkey</nz-form-label>
|
||||
<nz-form-control class="setting-control input" nzHasFeedback [nzErrorTip]="pushkeyErrorTip"
|
||||
[nzWarningTip]="syncFailedWarningTip" [nzValidateStatus]="
|
||||
pushkeyControl.valid && !syncStatus.pushkey ? 'warning' : pushkeyControl
|
||||
">
|
||||
<input id="pushkey" type="text" placeholder="" required nz-input formControlName="pushkey" />
|
||||
<ng-template #pushkeyErrorTip let-control>
|
||||
<ng-container *ngIf="control.hasError('required')">
|
||||
请输入 pushkey!
|
||||
</ng-container>
|
||||
<ng-container *ngIf="control.hasError('pattern')">
|
||||
pushkey 无效
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</form>
|
||||
@@ -0,0 +1,6 @@
|
||||
@use '../../../shared/styles/setting';
|
||||
|
||||
.setting-label {
|
||||
max-width: 5em !important;
|
||||
width: 5em !important;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { BarkSettingsComponent } from './bark-settings.component';
|
||||
|
||||
describe('BarkSettingsComponent', () => {
|
||||
let component: BarkSettingsComponent;
|
||||
let fixture: ComponentFixture<BarkSettingsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [BarkSettingsComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BarkSettingsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Component,
|
||||
OnInit,
|
||||
ChangeDetectionStrategy,
|
||||
Input,
|
||||
OnChanges,
|
||||
ChangeDetectorRef,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
FormBuilder,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
|
||||
import mapValues from 'lodash-es/mapValues';
|
||||
|
||||
import { BarkSettings } from '../../../shared/setting.model';
|
||||
import { filterValueChanges } from '../../../shared/rx-operators';
|
||||
import {
|
||||
SettingsSyncService,
|
||||
SyncStatus,
|
||||
calcSyncStatus,
|
||||
} from '../../../shared/services/settings-sync.service';
|
||||
import { SYNC_FAILED_WARNING_TIP } from 'src/app/settings/shared/constants/form';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bark-settings',
|
||||
templateUrl: './bark-settings.component.html',
|
||||
styleUrls: ['./bark-settings.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BarkSettingsComponent implements OnInit, OnChanges {
|
||||
@Input() settings!: BarkSettings;
|
||||
syncStatus!: SyncStatus<BarkSettings>;
|
||||
|
||||
readonly settingsForm: FormGroup;
|
||||
readonly syncFailedWarningTip = SYNC_FAILED_WARNING_TIP;
|
||||
|
||||
constructor(
|
||||
formBuilder: FormBuilder,
|
||||
private changeDetector: ChangeDetectorRef,
|
||||
private settingsSyncService: SettingsSyncService
|
||||
) {
|
||||
this.settingsForm = formBuilder.group({
|
||||
server: ['', [Validators.pattern(/^https?:\/\/.+/)]],
|
||||
pushkey: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.pattern(
|
||||
/^[a-zA-Z\d]+$/
|
||||
),
|
||||
],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
get serverControl() {
|
||||
return this.settingsForm.get('server') as FormControl;
|
||||
}
|
||||
|
||||
get pushkeyControl() {
|
||||
return this.settingsForm.get('pushkey') as FormControl;
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.syncStatus = mapValues(this.settings, () => true);
|
||||
console.log(this.settings);
|
||||
this.settingsForm.setValue(this.settings);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.settingsSyncService
|
||||
.syncSettings(
|
||||
'barkNotification',
|
||||
this.settings,
|
||||
this.settingsForm.valueChanges.pipe(
|
||||
filterValueChanges<Partial<BarkSettings>>(this.settingsForm)
|
||||
)
|
||||
)
|
||||
.subscribe((detail) => {
|
||||
this.syncStatus = { ...this.syncStatus, ...calcSyncStatus(detail) };
|
||||
this.changeDetector.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,12 @@
|
||||
<a class="setting-item" routerLink="email-notification"
|
||||
><span class="setting-label">邮箱通知</span
|
||||
><span class="setting-control"> <i nz-icon nzType="right"></i> </span
|
||||
></a>
|
||||
<a class="setting-item" routerLink="serverchan-notification"
|
||||
><span class="setting-label">ServerChan 通知</span
|
||||
><span class="setting-control"><i nz-icon nzType="right"></i></span
|
||||
></a>
|
||||
<a class="setting-item" routerLink="pushdeer-notification"
|
||||
><span class="setting-label">PushDeer 通知</span
|
||||
><span class="setting-control"><i nz-icon nzType="right"></i></span
|
||||
></a>
|
||||
<a class="setting-item" routerLink="pushplus-notification"
|
||||
><span class="setting-label">pushplus 通知</span
|
||||
><span class="setting-control"><i nz-icon nzType="right"></i></span
|
||||
></a>
|
||||
<a class="setting-item" routerLink="telegram-notification"
|
||||
><span class="setting-label">telegram 通知</span
|
||||
><span class="setting-control"><i nz-icon nzType="right"></i></span
|
||||
></a>
|
||||
<a class="setting-item" routerLink="email-notification"><span class="setting-label">邮箱通知</span><span
|
||||
class="setting-control"> <i nz-icon nzType="right"></i> </span></a>
|
||||
<a class="setting-item" routerLink="serverchan-notification"><span class="setting-label">ServerChan 通知</span><span
|
||||
class="setting-control"><i nz-icon nzType="right"></i></span></a>
|
||||
<a class="setting-item" routerLink="pushdeer-notification"><span class="setting-label">PushDeer 通知</span><span
|
||||
class="setting-control"><i nz-icon nzType="right"></i></span></a>
|
||||
<a class="setting-item" routerLink="pushplus-notification"><span class="setting-label">pushplus 通知</span><span
|
||||
class="setting-control"><i nz-icon nzType="right"></i></span></a>
|
||||
<a class="setting-item" routerLink="telegram-notification"><span class="setting-label">telegram 通知</span><span
|
||||
class="setting-control"><i nz-icon nzType="right"></i></span></a>
|
||||
<a class="setting-item" routerLink="bark-notification"><span class="setting-label">Bark 通知</span><span
|
||||
class="setting-control"><i nz-icon nzType="right"></i></span></a>
|
||||
@@ -66,6 +66,7 @@ export class PushdeerSettingsComponent implements OnInit, OnChanges {
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.syncStatus = mapValues(this.settings, () => true);
|
||||
console.log(this.settings);
|
||||
this.settingsForm.setValue(this.settings);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ export class EventSettingsComponent implements OnInit, OnChanges {
|
||||
| 'serverchanNotification'
|
||||
| 'pushdeerNotification'
|
||||
| 'pushplusNotification'
|
||||
| 'telegramNotification';
|
||||
| 'telegramNotification'
|
||||
| 'barkNotification';
|
||||
|
||||
syncStatus!: SyncStatus<NotificationSettings>;
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ export class MessageTemplateSettingsComponent implements OnInit, OnChanges {
|
||||
| 'serverchanNotification'
|
||||
| 'pushdeerNotification'
|
||||
| 'pushplusNotification'
|
||||
| 'telegramNotification';
|
||||
| 'telegramNotification'
|
||||
| 'barkNotification';
|
||||
|
||||
messageTypes!: MessageType[];
|
||||
beganMessageTemplateSettings!: CommonMessageTemplateSettings;
|
||||
@@ -50,7 +51,7 @@ export class MessageTemplateSettingsComponent implements OnInit, OnChanges {
|
||||
private changeDetector: ChangeDetectorRef,
|
||||
private message: NzMessageService,
|
||||
private settingService: SettingService
|
||||
) {}
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
switch (this.keyOfSettings) {
|
||||
|
||||
@@ -32,7 +32,8 @@ export class NotifierSettingsComponent implements OnInit, OnChanges {
|
||||
| 'serverchanNotification'
|
||||
| 'pushdeerNotification'
|
||||
| 'pushplusNotification'
|
||||
| 'telegramNotification';
|
||||
| 'telegramNotification'
|
||||
| 'barkNotification';
|
||||
|
||||
syncStatus!: SyncStatus<NotifierSettings>;
|
||||
|
||||
|
||||
@@ -57,4 +57,28 @@
|
||||
</ng-template>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label align-required"
|
||||
nzFor="server"
|
||||
nzNoColon
|
||||
>server</nz-form-label
|
||||
>
|
||||
<nz-form-control
|
||||
class="setting-control input"
|
||||
nzHasFeedback
|
||||
[nzErrorTip]="serverErrorTip"
|
||||
[nzWarningTip]="syncFailedWarningTip"
|
||||
[nzValidateStatus]="
|
||||
serverControl.valid && !syncStatus.server ? 'warning' : serverControl
|
||||
"
|
||||
>
|
||||
<input id="server" type="url" placeholder="默认为官方接口 https://api.telegram.org" nz-input formControlName="server" />
|
||||
<ng-template #serverErrorTip let-control>
|
||||
<ng-container *ngIf="control.hasError('pattern')">
|
||||
server 无效
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
</form>
|
||||
|
||||
@@ -45,6 +45,7 @@ export class TelegramSettingsComponent implements OnInit, OnChanges {
|
||||
this.settingsForm = formBuilder.group({
|
||||
token: ['', [Validators.required, Validators.pattern(/^[0-9]{8,10}:[a-zA-Z0-9_-]{35}$/)]],
|
||||
chatid: ['', [Validators.required, Validators.pattern(/^(-|[0-9]){0,}$/)]],
|
||||
server: ['', [Validators.pattern(/^https?:\/\/[a-zA-Z0-9-_.]+(:[0-9]+)?/)]],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,8 +57,13 @@ export class TelegramSettingsComponent implements OnInit, OnChanges {
|
||||
return this.settingsForm.get('chatid') as FormControl;
|
||||
}
|
||||
|
||||
get serverControl() {
|
||||
return this.settingsForm.get('server') as FormControl;
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.syncStatus = mapValues(this.settings, () => true);
|
||||
console.log(this.settings);
|
||||
this.settingsForm.setValue(this.settings);
|
||||
}
|
||||
|
||||
@@ -67,7 +73,7 @@ export class TelegramSettingsComponent implements OnInit, OnChanges {
|
||||
'telegramNotification',
|
||||
this.settings,
|
||||
this.settingsForm.valueChanges.pipe(
|
||||
filterValueChanges<TelegramSettings>(this.settingsForm)
|
||||
filterValueChanges<Partial<TelegramSettings>>(this.settingsForm)
|
||||
)
|
||||
)
|
||||
.subscribe((detail) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PushplusNotificationSettingsResolver } from './shared/services/pushplus
|
||||
import { TelegramNotificationSettingsResolver } from './shared/services/telegram-notification-settings.resolver';
|
||||
import { ServerchanNotificationSettingsResolver } from './shared/services/serverchan-notification-settings.resolver';
|
||||
import { PushdeerNotificationSettingsResolver } from './shared/services/pushdeer-notification-settings.resolver';
|
||||
import { BarkNotificationSettingsResolver } from './shared/services/bark-notification-settings.resolver';
|
||||
import { WebhookSettingsResolver } from './shared/services/webhook-settings.resolver';
|
||||
import { SettingsComponent } from './settings.component';
|
||||
import { EmailNotificationSettingsComponent } from './notification-settings/email-notification-settings/email-notification-settings.component';
|
||||
@@ -14,6 +15,7 @@ import { ServerchanNotificationSettingsComponent } from './notification-settings
|
||||
import { PushdeerNotificationSettingsComponent } from './notification-settings/pushdeer-notification-settings/pushdeer-notification-settings.component';
|
||||
import { PushplusNotificationSettingsComponent } from './notification-settings/pushplus-notification-settings/pushplus-notification-settings.component';
|
||||
import { TelegramNotificationSettingsComponent } from './notification-settings/telegram-notification-settings/telegram-notification-settings.component';
|
||||
import { BarkNotificationSettingsComponent } from './notification-settings/bark-notification-settings/bark-notification-settings.component';
|
||||
import { WebhookManagerComponent } from './webhook-settings/webhook-manager/webhook-manager.component';
|
||||
|
||||
const routes: Routes = [
|
||||
@@ -52,6 +54,13 @@ const routes: Routes = [
|
||||
settings: TelegramNotificationSettingsResolver,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'bark-notification',
|
||||
component: BarkNotificationSettingsComponent,
|
||||
resolve: {
|
||||
settings: BarkNotificationSettingsResolver,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'webhooks',
|
||||
component: WebhookManagerComponent,
|
||||
@@ -72,4 +81,4 @@ const routes: Routes = [
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class SettingsRoutingModule {}
|
||||
export class SettingsRoutingModule { }
|
||||
|
||||
@@ -70,6 +70,9 @@ import { BiliApiSettingsComponent } from './bili-api-settings/bili-api-settings.
|
||||
import { BaseApiUrlEditDialogComponent } from './bili-api-settings/base-api-url-edit-dialog/base-api-url-edit-dialog.component';
|
||||
import { BaseLiveApiUrlEditDialogComponent } from './bili-api-settings/base-live-api-url-edit-dialog/base-live-api-url-edit-dialog.component';
|
||||
import { BasePlayInfoApiUrlEditDialogComponent } from './bili-api-settings/base-play-info-api-url-edit-dialog/base-play-info-api-url-edit-dialog.component';
|
||||
import { BarkNotificationSettingsComponent } from './notification-settings/bark-notification-settings/bark-notification-settings.component';
|
||||
import { BarkSettingsComponent } from './notification-settings/bark-notification-settings/bark-settings/bark-settings.component';
|
||||
import { BarkNotificationSettingsResolver } from './shared/services/bark-notification-settings.resolver';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
@@ -98,6 +101,8 @@ import { BasePlayInfoApiUrlEditDialogComponent } from './bili-api-settings/base-
|
||||
PushplusSettingsComponent,
|
||||
TelegramNotificationSettingsComponent,
|
||||
TelegramSettingsComponent,
|
||||
BarkNotificationSettingsComponent,
|
||||
BarkSettingsComponent,
|
||||
NotifierSettingsComponent,
|
||||
WebhookManagerComponent,
|
||||
WebhookEditDialogComponent,
|
||||
@@ -147,7 +152,8 @@ import { BasePlayInfoApiUrlEditDialogComponent } from './bili-api-settings/base-
|
||||
PushdeerNotificationSettingsResolver,
|
||||
PushplusNotificationSettingsResolver,
|
||||
TelegramNotificationSettingsResolver,
|
||||
BarkNotificationSettingsResolver,
|
||||
WebhookSettingsResolver,
|
||||
],
|
||||
})
|
||||
export class SettingsModule {}
|
||||
export class SettingsModule { }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { BarkNotificationSettingsResolver } from './bark-notification-settings.resolver';
|
||||
|
||||
describe('TelegramNotificationSettingsResolverService', () => {
|
||||
let service: BarkNotificationSettingsResolver;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(BarkNotificationSettingsResolver);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
Resolve,
|
||||
RouterStateSnapshot,
|
||||
} from '@angular/router';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { NGXLogger } from 'ngx-logger';
|
||||
import { NzNotificationService } from 'ng-zorro-antd/notification';
|
||||
|
||||
import { retry } from '../../../shared/rx-operators';
|
||||
import { BarkNotificationSettings } from '../setting.model';
|
||||
import { SettingService } from './setting.service';
|
||||
|
||||
@Injectable()
|
||||
export class BarkNotificationSettingsResolver
|
||||
implements Resolve<BarkNotificationSettings>
|
||||
{
|
||||
constructor(
|
||||
private logger: NGXLogger,
|
||||
private notification: NzNotificationService,
|
||||
private settingService: SettingService
|
||||
) { }
|
||||
resolve(
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
): Observable<BarkNotificationSettings> {
|
||||
return this.settingService.getSettings(['barkNotification']).pipe(
|
||||
map((settings) => settings.barkNotification),
|
||||
retry(3, 300),
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
this.logger.error(
|
||||
'Failed to get bark notification settings:',
|
||||
error
|
||||
);
|
||||
this.notification.error('获取 bark 通知设置出错', error.message, {
|
||||
nzDuration: 0,
|
||||
});
|
||||
throw error;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,6 @@ export type LogLevel =
|
||||
export interface LoggingSettings {
|
||||
logDir: string;
|
||||
consoleLogLevel: LogLevel;
|
||||
maxBytes: number;
|
||||
backupCount: number;
|
||||
}
|
||||
|
||||
@@ -162,14 +161,25 @@ export const KEYS_OF_PUSHPLUS_SETTINGS = ['token', 'topic'] as const;
|
||||
export interface TelegramSettings {
|
||||
token: string;
|
||||
chatid: string;
|
||||
server: string;
|
||||
}
|
||||
|
||||
export const KEYS_OF_TELEGRAM_SETTINGS = ['token', 'chatid'] as const;
|
||||
export const KEYS_OF_TELEGRAM_SETTINGS = ['token', 'chatid', 'server'] as const;
|
||||
|
||||
export interface NotifierSettings {
|
||||
enabled: boolean;
|
||||
}
|
||||
export interface BarkSettings {
|
||||
server: string;
|
||||
pushkey: string;
|
||||
}
|
||||
|
||||
export const KEYS_OF_BARK_SETTINGS = ['server', 'pushkey'] as const;
|
||||
|
||||
export interface PushplusSettings {
|
||||
token: string;
|
||||
topic: string;
|
||||
}
|
||||
export const KEYS_OF_NOTIFIER_SETTINGS = ['enabled'] as const;
|
||||
|
||||
export interface NotificationSettings {
|
||||
@@ -202,6 +212,8 @@ export type PushplusMessageType =
|
||||
| MarkdownMessageType
|
||||
| HtmlMessageType;
|
||||
export type TelegramMessageType = MarkdownMessageType | HtmlMessageType;
|
||||
export type BarkMessageType = TextMessageType;
|
||||
|
||||
|
||||
export interface MessageTemplateSettings {
|
||||
beganMessageType: string;
|
||||
@@ -307,6 +319,20 @@ export interface TelegramMessageTemplateSettings {
|
||||
errorMessageTitle: string;
|
||||
errorMessageContent: string;
|
||||
}
|
||||
export interface BarkMessageTemplateSettings {
|
||||
beganMessageType: BarkMessageType;
|
||||
beganMessageTitle: string;
|
||||
beganMessageContent: string;
|
||||
endedMessageType: BarkMessageType;
|
||||
endedMessageTitle: string;
|
||||
endedMessageContent: string;
|
||||
spaceMessageType: BarkMessageType;
|
||||
spaceMessageTitle: string;
|
||||
spaceMessageContent: string;
|
||||
errorMessageType: BarkMessageType;
|
||||
errorMessageTitle: string;
|
||||
errorMessageContent: string;
|
||||
}
|
||||
|
||||
export type EmailNotificationSettings = EmailSettings &
|
||||
NotifierSettings &
|
||||
@@ -333,6 +359,11 @@ export type TelegramNotificationSettings = TelegramSettings &
|
||||
NotificationSettings &
|
||||
TelegramMessageTemplateSettings;
|
||||
|
||||
export type BarkNotificationSettings = BarkSettings &
|
||||
NotifierSettings &
|
||||
NotificationSettings &
|
||||
BarkMessageTemplateSettings;
|
||||
|
||||
export interface WebhookEventSettings {
|
||||
liveBegan: boolean;
|
||||
liveEnded: boolean;
|
||||
@@ -371,6 +402,7 @@ export interface Settings {
|
||||
pushdeerNotification: PushdeerNotificationSettings;
|
||||
pushplusNotification: PushplusNotificationSettings;
|
||||
telegramNotification: TelegramNotificationSettings;
|
||||
barkNotification: BarkNotificationSettings;
|
||||
webhooks: WebhookSettings[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user