Compare commits

..

29 Commits

Author SHA1 Message Date
acgnhik
6c1ed1bbb7 release: 1.13.0 2023-02-19 15:15:59 +08:00
acgnhik
996dca3e94 chore: update README 2023-02-19 14:46:00 +08:00
lanhao34
d54721d553 Change docker timezone to Asia/Shanghai 2023-02-19 13:37:10 +08:00
acgnhik
97b1b3cd02 refactor: refactor code to compatible with Python 3.11 2023-02-19 13:19:20 +08:00
acgnhik
b80019a258 perf: on Linux, use malloc_trim to release memory.
ref:
http://www.cplusplus-soup.com/2010/01/freedelete-not-returning-memory-to-os.html
https://lemire.me/blog/2020/03/03/calling-free-or-delete/
2023-02-19 13:08:07 +08:00
acgnhik
74dd739ec7 fix: fix RuntimeWarning: coroutine 'Live.get_live_stream_url' was never awaited 2023-02-19 12:28:13 +08:00
acgnhik
486e2ba552 refactor: avoid errors caused by invalid small FLV files 2023-01-02 12:09:40 +08:00
acgnhik
60a8f23a14 refactor: update room info before handling status changes 2022-12-31 20:50:50 +08:00
acgnhik
34d9aa63ef feat: improve hls stream recorders
avoid excessive memory being occupied
2022-12-26 21:24:28 +08:00
acgnhik
6469881220 feat: improve live monitoring 2022-12-25 20:51:37 +08:00
acgnhik
50971eeb0e chore: update packages and python 2022-12-18 18:20:29 +08:00
acgnhik
8547834c2a release: 1.12.0
fix #129
fix #132
fix #136
2022-12-04 14:54:01 +08:00
acgnhik
08130d5e61 refactor: refactor danmaku client 2022-12-04 14:29:07 +08:00
acgnhiki
46811d4677 Merge pull request #135 from sihuan/custom_tg_api
自定义 telegram bot api 地址
2022-11-27 13:43:29 +08:00
SiHuan
dd819e65e8 添加自定义 telegram bot api 地址 2022-11-26 15:21:00 +08:00
acgnhik
4cd3acd09d release: 1.11.1 2022-11-06 13:23:53 +08:00
acgnhik
f6249c1fe3 fix: meta.json files not been removed 2022-11-06 13:17:36 +08:00
acgnhik
57e52c1c01 release: 1.11.0
resolve #114
fix #124
2022-11-06 12:18:03 +08:00
acgnhik
d4a4904d54 feat: rotating log files by time instead of file size 2022-11-06 12:15:11 +08:00
acgnhiki
3a7facce0b Merge pull request #123 from ygguorun/feature-bark
feat: add bark notification
2022-11-06 11:50:26 +08:00
acgnhik
26b389633e fix: fix fetching segments failed continuously 2022-11-06 11:04:31 +08:00
acgnhik
2ad6847f42 fix: workaround for receiving danmu messages continuously timed out 2022-11-06 10:59:58 +08:00
acgnhik
8ad8d53a34 fix: workaround for segments corrupted continuously 2022-11-05 10:45:09 +08:00
acgnhik
6310ce87c6 fix: fix mistakes 2022-11-05 10:43:15 +08:00
acgnhik
f625e85595 fix: fix downloading fmp4 segments stopped due to the stream interrupted 2022-11-01 13:33:20 +08:00
acgnhik
121b5f9648 feat: use pyav 2022-10-23 13:37:38 +08:00
ygguorun
91195fb999 add bark notification 2022-10-20 17:31:48 +08:00
acgnhik
a64d2d7153 refactor: refactor segment_fetcher 2022-10-01 15:52:01 +08:00
acgnhik
f828ffd885 fix: fix ValueError: list.remove(x): x not in list 2022-09-22 17:20:00 +08:00
71 changed files with 1251 additions and 650 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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
[![JetBrains Logo (Main) logo](https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg)](https://jb.gg/OpenSource)
## 其它相关工具或项目
| 名称 | 链接 | 简介 |

View File

@@ -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 =

View File

@@ -1,3 +1,3 @@
__prog__ = 'blrec'
__version__ = '1.10.0'
__version__ = '1.13.0'
__github__ = 'https://github.com/acgnhiki/blrec'

View File

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

View File

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

View File

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

View File

@@ -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')
),
)
)

View File

@@ -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')),
)
)

View File

@@ -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),
)

View File

@@ -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')

View File

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

View File

@@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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>

View File

@@ -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": [

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

View File

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

View File

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

View File

@@ -1,2 +1,10 @@
class SegmentDataCorrupted(ValueError):
pass
class NoNewSegments(Exception):
pass
class FetchSegmentError(Exception):
pass

View File

@@ -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',
)

View File

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

View File

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

View 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)

View File

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

View File

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

View File

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

View File

@@ -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',
)

View File

@@ -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')

View File

@@ -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'])

View File

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

View File

@@ -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',

View File

@@ -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

View File

@@ -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

View File

@@ -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',
]

View File

@@ -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
View 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

View File

@@ -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')

View 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

View File

@@ -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

View File

@@ -55,5 +55,6 @@ AliasKeyOfSettings = Literal[
'pushdeerNotification',
'pushplusNotification',
'telegramNotification',
'barkNotification',
'webhooks',
]

View File

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

View File

@@ -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

View File

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

View File

@@ -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>

View File

@@ -0,0 +1,2 @@
@use '../../shared/styles/setting';

View File

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

View File

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

View File

@@ -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>

View File

@@ -0,0 +1,6 @@
@use '../../../shared/styles/setting';
.setting-label {
max-width: 5em !important;
width: 5em !important;
}

View File

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

View File

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

View File

@@ -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>

View File

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

View File

@@ -32,7 +32,8 @@ export class EventSettingsComponent implements OnInit, OnChanges {
| 'serverchanNotification'
| 'pushdeerNotification'
| 'pushplusNotification'
| 'telegramNotification';
| 'telegramNotification'
| 'barkNotification';
syncStatus!: SyncStatus<NotificationSettings>;

View File

@@ -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) {

View File

@@ -32,7 +32,8 @@ export class NotifierSettingsComponent implements OnInit, OnChanges {
| 'serverchanNotification'
| 'pushdeerNotification'
| 'pushplusNotification'
| 'telegramNotification';
| 'telegramNotification'
| 'barkNotification';
syncStatus!: SyncStatus<NotifierSettings>;

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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 { }

View File

@@ -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 { }

View File

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

View File

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

View File

@@ -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[];
}