Compare commits

..

15 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
33 changed files with 400 additions and 214 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,17 @@
# 更新日志
## 1.13.0
- 支持 Python 3.11
- 改进直播监控
- 优化在 Linux 下的内存占用
- docker 时区设置为默认 `Asia/Shanghai`
## 1.12.0
- 支持自定义 Telegram bot api 地址
- 重构弹幕客户端: 避免接收的数据有问题导致崩溃,调整弹幕接收超时时间。
## 1.11.1
- 修复 `meta.json` 文件没被删除

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,14 +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.11.1'
__version__ = '1.13.0'
__github__ = 'https://github.com/acgnhiki/blrec'

View File

@@ -1,43 +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,
BarkNotifier,
)
from .webhook import WebHookEmitter
logger = logging.getLogger(__name__)
@@ -105,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__}')
@@ -112,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')
@@ -128,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)
@@ -136,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:
@@ -214,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)
@@ -249,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(
@@ -291,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()
@@ -320,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()

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,48 +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)
except asyncio.TimeoutError as e:
logger.debug(f'Failed to receive message due to: {repr(e)}')
continue
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
@@ -84,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,
@@ -103,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
@@ -130,22 +130,19 @@ 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('StreamRecorder'))
utils_ops.observe_on_new_thread(
queue_size=10, thread_name=f'StreamRecorder::{self._live.room_id}'
),
self._segment_remuxer,
self._segment_parser,
@@ -160,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

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

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.0560bf422fb1ab67.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.dbd09d2079405adc.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": 1667707262978,
"timestamp": 1669447100875,
"index": "/index.html",
"assetGroups": [
{
@@ -15,14 +15,14 @@
"/146.5a8902910bda9e87.js",
"/183.ee55fc76717674c3.js",
"/45.c90c3cea2bf1a66e.js",
"/548.91bbb60199d9e944.js",
"/91.3c224fe84835dadd.js",
"/548.4789e17f7acce023.js",
"/91.07ca0767ccc21566.js",
"/common.858f777e9296e6f2.js",
"/index.html",
"/main.dbd09d2079405adc.js",
"/manifest.webmanifest",
"/polyfills.4b08448aee19bb22.js",
"/runtime.0560bf422fb1ab67.js",
"/runtime.a904720a2e39ffc3.js",
"/styles.2e152d608221c2ee.css"
],
"patterns": []
@@ -1638,8 +1638,8 @@
"/146.5a8902910bda9e87.js": "d9c33c7073662699f00f46f3a384ae5b749fdef9",
"/183.ee55fc76717674c3.js": "2628c996ec80a6c6703d542d34ac95194283bcf8",
"/45.c90c3cea2bf1a66e.js": "e5bfb8cf3803593e6b8ea14c90b3d3cb6a066764",
"/548.91bbb60199d9e944.js": "062b3a6424284294e5774bcb08ca76df7b0c4216",
"/91.3c224fe84835dadd.js": "2e3cdb6c44a8cf3241fe8dd89b27c37f212768f8",
"/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": "17482e27906b5ae0447920edbf4bf4f4c0c1838b",
"/index.html": "9ba0d26d371e607af065904e06d098a0698f75a3",
"/main.dbd09d2079405adc.js": "2f7284b616ed9fc433b612c9dca53dc06a0f3aa1",
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
"/manifest.webmanifest": "0c4534b4c868d756691b1b4372cecb2efce47c6d",
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
"/runtime.0560bf422fb1ab67.js": "74c07903a5fd6d43a0d7690a93b0927f8eead22c",
"/runtime.a904720a2e39ffc3.js": "d9eb86363e3840a15e5659af6f04f29e19df9233",
"/styles.2e152d608221c2ee.css": "9830389a46daa5b4511e0dd343aad23ca9f9690f"
},
"navigationUrls": [

View File

@@ -1 +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(p=>r.O[p](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:"3c224fe84835dadd",103:"5b5d2a6e5a8a7479",146:"5a8902910bda9e87",183:"ee55fc76717674c3",548:"91bbb60199d9e944",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,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=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),b=u&&u.target&&u.target.src;l.message="Loading chunk "+i+" failed.\n("+s+": "+b+")",l.name="ChunkLoadError",l.type=s,l.request=b,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(b=>0!==e[b])){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))})()})();
(()=>{"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

@@ -218,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
@@ -238,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:

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
(

View File

@@ -404,6 +404,7 @@ class PushplusSettings(BaseModel):
class TelegramSettings(BaseModel):
token: str = ''
chatid: str = ''
server: str = ''
@validator('token')
def _validate_token(cls, value: str) -> str:
@@ -417,6 +418,11 @@ 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 = ''

View File

@@ -378,6 +378,7 @@ 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

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

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

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

@@ -161,9 +161,10 @@ 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;