parent
d5d9ece36a
commit
5ebeb38b69
3
.flake8
3
.flake8
@ -1,5 +1,6 @@
|
||||
[flake8]
|
||||
ignore = D203, W504
|
||||
max-line-length = 88
|
||||
ignore = D203, W504, W503
|
||||
exclude =
|
||||
__*,
|
||||
.*,
|
||||
|
18
CHANGELOG.md
18
CHANGELOG.md
@ -1,5 +1,23 @@
|
||||
# 更新日志
|
||||
|
||||
## 1.7.0
|
||||
|
||||
- 添加封面保存策略
|
||||
- 添加 Telegram bot 通知
|
||||
- 添加 PushDeer 通知
|
||||
- 废弃录制 HLS(ts) 流
|
||||
- 在设定时间内没有 fmp4 流自动切换录制 flv 流
|
||||
|
||||
### P.S.
|
||||
|
||||
录制 fmp4 流基本没什么问题了
|
||||
|
||||
录制 fmp4 流基本不受网络波动影响,大概是不会录制到二压画质的。
|
||||
|
||||
人气比较高会被二压的直播间大都是有 fmp4 流的。
|
||||
|
||||
WEB 端直播播放器是 `Hls7Player` 的直播间支持录制 fmp4 流, `fMp4Player` 则不支持。
|
||||
|
||||
## 1.6.2
|
||||
|
||||
- 忽略 Windows 注册表 JavaScript MIME 设置 (issue #12, 27)
|
||||
|
@ -4,3 +4,13 @@ requires = [
|
||||
"wheel >= 0.37, < 0.38.0",
|
||||
]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ['py38']
|
||||
include = '\.py$'
|
||||
skip-string-normalization = true
|
||||
skip-magic-trailing-comma = true
|
||||
|
||||
[tool.isort]
|
||||
profile = 'black'
|
||||
|
@ -36,10 +36,11 @@ include_package_data = True
|
||||
python_requires = >= 3.8
|
||||
install_requires =
|
||||
typing-extensions >= 3.10.0.0
|
||||
ordered-set >= 4.1.0, < 5.0.0
|
||||
fastapi >= 0.70.0, < 0.71.0
|
||||
email_validator >= 1.1.3, < 2.0.0
|
||||
click < 8.1.0
|
||||
typer >= 0.4.0, < 0.5.0
|
||||
typer >= 0.4.1, < 0.5.0
|
||||
aiohttp >= 3.8.1, < 4.0.0
|
||||
requests >= 2.24.0, < 3.0.0
|
||||
aiofiles >= 0.8.0, < 0.9.0
|
||||
@ -61,7 +62,9 @@ install_requires =
|
||||
[options.extras_require]
|
||||
dev =
|
||||
flake8 >= 4.0.1
|
||||
mypy >= 0.910
|
||||
mypy == 0.910 # https://github.com/samuelcolvin/pydantic/issues/3528
|
||||
isort >= 5.10.1
|
||||
black >= 22.3.0
|
||||
|
||||
setuptools >= 59.4.0
|
||||
wheel >= 0.37
|
||||
|
@ -1,4 +1,4 @@
|
||||
|
||||
__prog__ = 'blrec'
|
||||
__version__ = '1.6.2'
|
||||
__version__ = '1.7.0'
|
||||
__github__ = 'https://github.com/acgnhiki/blrec'
|
||||
|
97
src/blrec/core/cover_downloader.py
Normal file
97
src/blrec/core/cover_downloader.py
Normal file
@ -0,0 +1,97 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import Set
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
from tenacity import retry, stop_after_attempt, wait_fixed
|
||||
|
||||
from ..bili.live import Live
|
||||
from ..exception import exception_callback
|
||||
from ..logging.room_id import aio_task_with_room_id
|
||||
from ..path import cover_path
|
||||
from ..utils.hash import sha1sum
|
||||
from ..utils.mixins import SwitchableMixin
|
||||
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
||||
|
||||
__all__ = ('CoverDownloader',)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CoverSaveStrategy(Enum):
|
||||
DEFAULT = 'default'
|
||||
DEDUP = 'dedup'
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
# workaround for value serialization
|
||||
def __repr__(self) -> str:
|
||||
return str(self)
|
||||
|
||||
|
||||
class CoverDownloader(StreamRecorderEventListener, SwitchableMixin):
|
||||
def __init__(
|
||||
self,
|
||||
live: Live,
|
||||
stream_recorder: StreamRecorder,
|
||||
*,
|
||||
save_cover: bool = False,
|
||||
cover_save_strategy: CoverSaveStrategy = CoverSaveStrategy.DEFAULT,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._live = live
|
||||
self._stream_recorder = stream_recorder
|
||||
self._lock: asyncio.Lock = asyncio.Lock()
|
||||
self._sha1_set: Set[str] = set()
|
||||
self.save_cover = save_cover
|
||||
self.cover_save_strategy = cover_save_strategy
|
||||
|
||||
def _do_enable(self) -> None:
|
||||
self._sha1_set.clear()
|
||||
self._stream_recorder.add_listener(self)
|
||||
logger.debug('Enabled cover downloader')
|
||||
|
||||
def _do_disable(self) -> None:
|
||||
self._stream_recorder.remove_listener(self)
|
||||
logger.debug('Disabled cover downloader')
|
||||
|
||||
async def on_video_file_completed(self, video_path: str) -> None:
|
||||
async with self._lock:
|
||||
if not self.save_cover:
|
||||
return
|
||||
task = asyncio.create_task(self._save_cover(video_path))
|
||||
task.add_done_callback(exception_callback)
|
||||
|
||||
@aio_task_with_room_id
|
||||
async def _save_cover(self, video_path: str) -> None:
|
||||
try:
|
||||
await self._live.update_info()
|
||||
cover_url = self._live.room_info.cover
|
||||
data = await self._fetch_cover(cover_url)
|
||||
sha1 = sha1sum(data)
|
||||
if (
|
||||
self.cover_save_strategy == CoverSaveStrategy.DEDUP
|
||||
and sha1 in self._sha1_set
|
||||
):
|
||||
return
|
||||
path = cover_path(video_path, ext=cover_url.rsplit('.', 1)[-1])
|
||||
await self._save_file(path, data)
|
||||
self._sha1_set.add(sha1)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to save cover image: {repr(e)}')
|
||||
else:
|
||||
logger.info(f'Saved cover image: {path}')
|
||||
|
||||
@retry(reraise=True, wait=wait_fixed(1), stop=stop_after_attempt(3))
|
||||
async def _fetch_cover(self, url: str) -> bytes:
|
||||
async with aiohttp.ClientSession(raise_for_status=True) as session:
|
||||
async with session.get(url) as response:
|
||||
return await response.read()
|
||||
|
||||
async def _save_file(self, path: str, data: bytes) -> None:
|
||||
async with aiofiles.open(path, 'wb') as file:
|
||||
await file.write(data)
|
@ -12,9 +12,7 @@ from tenacity import (
|
||||
|
||||
from .. import __version__, __prog__, __github__
|
||||
from .danmaku_receiver import DanmakuReceiver, DanmuMsg
|
||||
from .base_stream_recorder import (
|
||||
BaseStreamRecorder, StreamRecorderEventListener
|
||||
)
|
||||
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
||||
from .statistics import StatisticsCalculator
|
||||
from ..bili.live import Live
|
||||
from ..exception import exception_callback, submit_exception
|
||||
@ -51,7 +49,7 @@ class DanmakuDumper(
|
||||
def __init__(
|
||||
self,
|
||||
live: Live,
|
||||
stream_recorder: BaseStreamRecorder,
|
||||
stream_recorder: StreamRecorder,
|
||||
danmaku_receiver: DanmakuReceiver,
|
||||
*,
|
||||
danmu_uname: bool = False,
|
||||
@ -72,6 +70,7 @@ class DanmakuDumper(
|
||||
self.record_guard_buy = record_guard_buy
|
||||
self.record_super_chat = record_super_chat
|
||||
|
||||
self._lock: asyncio.Lock = asyncio.Lock()
|
||||
self._path: Optional[str] = None
|
||||
self._files: List[str] = []
|
||||
self._calculator = StatisticsCalculator(interval=60)
|
||||
@ -92,14 +91,6 @@ class DanmakuDumper(
|
||||
def dumping_path(self) -> Optional[str]:
|
||||
return self._path
|
||||
|
||||
def change_stream_recorder(
|
||||
self, stream_recorder: BaseStreamRecorder
|
||||
) -> None:
|
||||
self._stream_recorder.remove_listener(self)
|
||||
self._stream_recorder = stream_recorder
|
||||
self._stream_recorder.add_listener(self)
|
||||
logger.debug('Changed stream recorder')
|
||||
|
||||
def _do_enable(self) -> None:
|
||||
self._stream_recorder.add_listener(self)
|
||||
logger.debug('Enabled danmaku dumper')
|
||||
@ -124,12 +115,14 @@ class DanmakuDumper(
|
||||
async def on_video_file_created(
|
||||
self, video_path: str, record_start_time: int
|
||||
) -> None:
|
||||
async with self._lock:
|
||||
self._path = danmaku_path(video_path)
|
||||
self._record_start_time = record_start_time
|
||||
self._files.append(self._path)
|
||||
self._start_dumping()
|
||||
|
||||
async def on_video_file_completed(self, video_path: str) -> None:
|
||||
async with self._lock:
|
||||
await self._stop_dumping()
|
||||
self._path = None
|
||||
|
||||
|
@ -1,3 +0,0 @@
|
||||
|
||||
class FailedToFetchSegments(Exception):
|
||||
pass
|
@ -1,51 +1,33 @@
|
||||
import io
|
||||
import errno
|
||||
import logging
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import urllib3
|
||||
import requests
|
||||
import urllib3
|
||||
from tenacity import TryAgain
|
||||
from tqdm import tqdm
|
||||
from tenacity import (
|
||||
retry_if_result,
|
||||
retry_if_not_exception_type,
|
||||
Retrying,
|
||||
TryAgain,
|
||||
)
|
||||
|
||||
from .stream_analyzer import StreamProfile
|
||||
from .base_stream_recorder import BaseStreamRecorder, StreamProxy
|
||||
from .retry import wait_exponential_for_same_exceptions, before_sleep_log
|
||||
from ..bili.live import Live
|
||||
from ..bili.typing import StreamFormat, QualityNumber
|
||||
from ..flv.stream_processor import StreamProcessor
|
||||
from ..utils.mixins import AsyncCooperationMixin, AsyncStoppableMixin
|
||||
from ..bili.typing import QualityNumber
|
||||
from ..flv.exceptions import FlvDataError, FlvStreamCorruptedError
|
||||
from ..bili.exceptions import (
|
||||
LiveRoomHidden, LiveRoomLocked, LiveRoomEncrypted, NoStreamAvailable,
|
||||
)
|
||||
from ..flv.stream_processor import StreamProcessor
|
||||
from .stream_analyzer import StreamProfile
|
||||
from .stream_recorder_impl import StreamProxy, StreamRecorderImpl
|
||||
|
||||
|
||||
__all__ = 'FLVStreamRecorder',
|
||||
__all__ = 'FLVStreamRecorderImpl',
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FLVStreamRecorder(
|
||||
BaseStreamRecorder,
|
||||
AsyncCooperationMixin,
|
||||
AsyncStoppableMixin,
|
||||
):
|
||||
class FLVStreamRecorderImpl(StreamRecorderImpl):
|
||||
def __init__(
|
||||
self,
|
||||
live: Live,
|
||||
out_dir: str,
|
||||
path_template: str,
|
||||
*,
|
||||
stream_format: StreamFormat = 'flv',
|
||||
quality_number: QualityNumber = 10000,
|
||||
buffer_size: Optional[int] = None,
|
||||
read_timeout: Optional[int] = None,
|
||||
@ -57,7 +39,7 @@ class FLVStreamRecorder(
|
||||
live=live,
|
||||
out_dir=out_dir,
|
||||
path_template=path_template,
|
||||
stream_format=stream_format,
|
||||
stream_format='flv',
|
||||
quality_number=quality_number,
|
||||
buffer_size=buffer_size,
|
||||
read_timeout=read_timeout,
|
||||
@ -73,6 +55,7 @@ class FLVStreamRecorder(
|
||||
desc='Recording',
|
||||
unit='B',
|
||||
unit_scale=True,
|
||||
unit_divisor=1024,
|
||||
postfix=self._make_pbar_postfix(),
|
||||
) as progress_bar:
|
||||
self._progress_bar = progress_bar
|
||||
@ -116,43 +99,6 @@ class FLVStreamRecorder(
|
||||
self._emit_event('stream_recording_stopped')
|
||||
logger.debug('Stream recorder thread stopped')
|
||||
|
||||
def _main_loop(self) -> None:
|
||||
for attempt in Retrying(
|
||||
reraise=True,
|
||||
retry=(
|
||||
retry_if_result(lambda r: not self._stopped) |
|
||||
retry_if_not_exception_type((OSError, NotImplementedError))
|
||||
),
|
||||
wait=wait_exponential_for_same_exceptions(max=60),
|
||||
before_sleep=before_sleep_log(logger, logging.DEBUG, 'main_loop'),
|
||||
):
|
||||
with attempt:
|
||||
try:
|
||||
self._streaming_loop()
|
||||
except NoStreamAvailable as e:
|
||||
logger.warning(f'No stream available: {repr(e)}')
|
||||
if not self._stopped:
|
||||
raise TryAgain
|
||||
except OSError as e:
|
||||
logger.critical(repr(e), exc_info=e)
|
||||
if e.errno == errno.ENOSPC:
|
||||
# OSError(28, 'No space left on device')
|
||||
self._handle_exception(e)
|
||||
self._stopped = True
|
||||
raise TryAgain
|
||||
except LiveRoomHidden:
|
||||
logger.error('The live room has been hidden!')
|
||||
self._stopped = True
|
||||
except LiveRoomLocked:
|
||||
logger.error('The live room has been locked!')
|
||||
self._stopped = True
|
||||
except LiveRoomEncrypted:
|
||||
logger.error('The live room has been encrypted!')
|
||||
self._stopped = True
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
self._handle_exception(e)
|
||||
|
||||
def _streaming_loop(self) -> None:
|
||||
url = self._get_live_stream_url()
|
||||
|
@ -1,68 +1,57 @@
|
||||
import io
|
||||
import time
|
||||
import errno
|
||||
import logging
|
||||
from queue import Queue, Empty
|
||||
from threading import Thread, Event, Lock, Condition
|
||||
from datetime import datetime
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from queue import Empty, Queue
|
||||
from threading import Condition, Event, Lock, Thread
|
||||
from typing import Final, List, Optional, Set
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from typing import List, Set, Optional
|
||||
|
||||
import urllib3
|
||||
import requests
|
||||
import m3u8
|
||||
import requests
|
||||
import urllib3
|
||||
from m3u8.model import Segment
|
||||
from tqdm import tqdm
|
||||
from ordered_set import OrderedSet
|
||||
from tenacity import (
|
||||
retry,
|
||||
wait_exponential,
|
||||
stop_after_delay,
|
||||
retry_if_result,
|
||||
retry_if_exception_type,
|
||||
retry_if_not_exception_type,
|
||||
RetryError,
|
||||
Retrying,
|
||||
TryAgain,
|
||||
RetryError,
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
retry_if_not_exception_type,
|
||||
retry_if_result,
|
||||
stop_after_delay,
|
||||
wait_exponential,
|
||||
)
|
||||
from tqdm import tqdm
|
||||
|
||||
from .stream_remuxer import StreamRemuxer
|
||||
from .stream_analyzer import ffprobe, StreamProfile
|
||||
from .base_stream_recorder import BaseStreamRecorder, StreamProxy
|
||||
from .exceptions import FailedToFetchSegments
|
||||
from .retry import wait_exponential_for_same_exceptions, before_sleep_log
|
||||
from ..bili.live import Live
|
||||
from ..bili.typing import StreamFormat, QualityNumber
|
||||
from ..flv.stream_processor import StreamProcessor
|
||||
from ..bili.typing import QualityNumber
|
||||
from ..flv.exceptions import FlvDataError, FlvStreamCorruptedError
|
||||
from ..utils.mixins import (
|
||||
AsyncCooperationMixin, AsyncStoppableMixin, SupportDebugMixin
|
||||
)
|
||||
from ..bili.exceptions import (
|
||||
LiveRoomHidden, LiveRoomLocked, LiveRoomEncrypted, NoStreamAvailable,
|
||||
)
|
||||
from ..flv.stream_processor import StreamProcessor
|
||||
from ..utils.mixins import SupportDebugMixin
|
||||
from .stream_analyzer import StreamProfile, ffprobe
|
||||
from .stream_recorder_impl import StreamProxy, StreamRecorderImpl
|
||||
from .stream_remuxer import StreamRemuxer
|
||||
|
||||
|
||||
__all__ = 'HLSStreamRecorder',
|
||||
__all__ = 'HLSStreamRecorderImpl',
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HLSStreamRecorder(
|
||||
BaseStreamRecorder,
|
||||
AsyncCooperationMixin,
|
||||
AsyncStoppableMixin,
|
||||
SupportDebugMixin,
|
||||
):
|
||||
class FailedToFetchSegments(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HLSStreamRecorderImpl(StreamRecorderImpl, SupportDebugMixin):
|
||||
def __init__(
|
||||
self,
|
||||
live: Live,
|
||||
out_dir: str,
|
||||
path_template: str,
|
||||
*,
|
||||
stream_format: StreamFormat = 'flv',
|
||||
quality_number: QualityNumber = 10000,
|
||||
buffer_size: Optional[int] = None,
|
||||
read_timeout: Optional[int] = None,
|
||||
@ -74,7 +63,7 @@ class HLSStreamRecorder(
|
||||
live=live,
|
||||
out_dir=out_dir,
|
||||
path_template=path_template,
|
||||
stream_format=stream_format,
|
||||
stream_format='fmp4',
|
||||
quality_number=quality_number,
|
||||
buffer_size=buffer_size,
|
||||
read_timeout=read_timeout,
|
||||
@ -87,7 +76,8 @@ class HLSStreamRecorder(
|
||||
self._ready_to_fetch_segments = Condition()
|
||||
self._failed_to_fetch_segments = Event()
|
||||
self._stream_analysed_lock = Lock()
|
||||
self._last_segment_uris: Set[str] = set()
|
||||
self._last_seg_uris: OrderedSet[str] = OrderedSet()
|
||||
self._MAX_LAST_SEG_URIS: Final[int] = 30
|
||||
|
||||
def _run(self) -> None:
|
||||
logger.debug('Stream recorder thread started')
|
||||
@ -103,7 +93,10 @@ class HLSStreamRecorder(
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update(self._live.headers)
|
||||
|
||||
self._stream_remuxer = StreamRemuxer(self._live.room_id)
|
||||
self._stream_remuxer = StreamRemuxer(
|
||||
self._live.room_id,
|
||||
remove_filler_data=True,
|
||||
)
|
||||
self._segment_queue: Queue[Segment] = Queue(maxsize=1000)
|
||||
self._segment_data_queue: Queue[bytes] = Queue(maxsize=100)
|
||||
self._stream_host_available = Event()
|
||||
@ -139,7 +132,7 @@ class HLSStreamRecorder(
|
||||
self._segment_data_feeder_thread.join(timeout=10)
|
||||
self._stream_remuxer.stop()
|
||||
self._stream_remuxer.raise_for_exception()
|
||||
self._last_segment_uris.clear()
|
||||
self._last_seg_uris.clear()
|
||||
del self._segment_queue
|
||||
del self._segment_data_queue
|
||||
except TryAgain:
|
||||
@ -153,44 +146,6 @@ class HLSStreamRecorder(
|
||||
self._emit_event('stream_recording_stopped')
|
||||
logger.debug('Stream recorder thread stopped')
|
||||
|
||||
def _main_loop(self) -> None:
|
||||
for attempt in Retrying(
|
||||
reraise=True,
|
||||
retry=(
|
||||
retry_if_result(lambda r: not self._stopped) |
|
||||
retry_if_not_exception_type((OSError, NotImplementedError))
|
||||
),
|
||||
wait=wait_exponential_for_same_exceptions(max=60),
|
||||
before_sleep=before_sleep_log(logger, logging.DEBUG, 'main_loop'),
|
||||
):
|
||||
with attempt:
|
||||
try:
|
||||
self._streaming_loop()
|
||||
except NoStreamAvailable as e:
|
||||
logger.warning(f'No stream available: {repr(e)}')
|
||||
if not self._stopped:
|
||||
raise TryAgain
|
||||
except OSError as e:
|
||||
logger.critical(repr(e), exc_info=e)
|
||||
if e.errno == errno.ENOSPC:
|
||||
# OSError(28, 'No space left on device')
|
||||
self._handle_exception(e)
|
||||
self._stopped = True
|
||||
raise TryAgain
|
||||
except LiveRoomHidden:
|
||||
logger.error('The live room has been hidden!')
|
||||
self._stopped = True
|
||||
except LiveRoomLocked:
|
||||
logger.error('The live room has been locked!')
|
||||
self._stopped = True
|
||||
except LiveRoomEncrypted:
|
||||
logger.error('The live room has been encrypted!')
|
||||
self._stopped = True
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
self._handle_exception(e)
|
||||
self._stopped = True
|
||||
|
||||
def _streaming_loop(self) -> None:
|
||||
url = self._get_live_stream_url()
|
||||
|
||||
@ -247,32 +202,29 @@ class HLSStreamRecorder(
|
||||
self._stream_analysed = False
|
||||
continue
|
||||
|
||||
uris: Set[str] = set()
|
||||
curr_seg_uris: Set[str] = set()
|
||||
for seg in playlist.segments:
|
||||
uris.add(seg.uri)
|
||||
if seg.uri not in self._last_segment_uris:
|
||||
curr_seg_uris.add(seg.uri)
|
||||
if seg.uri not in self._last_seg_uris:
|
||||
self._segment_queue.put(seg, timeout=60)
|
||||
self._last_seg_uris.add(seg.uri)
|
||||
if len(self._last_seg_uris) > self._MAX_LAST_SEG_URIS:
|
||||
self._last_seg_uris.pop(0)
|
||||
|
||||
if (
|
||||
self._last_segment_uris and
|
||||
not uris.intersection(self._last_segment_uris)
|
||||
self._last_seg_uris and
|
||||
not curr_seg_uris.intersection(self._last_seg_uris)
|
||||
):
|
||||
logger.debug(
|
||||
'segments broken!\n'
|
||||
f'last segments: {self._last_segment_uris}\n'
|
||||
f'current segments: {uris}'
|
||||
f'last segments uris: {self._last_seg_uris}\n'
|
||||
f'current segments uris: {curr_seg_uris}'
|
||||
)
|
||||
with self._stream_analysed_lock:
|
||||
self._stream_analysed = False
|
||||
|
||||
self._last_segment_uris = uris
|
||||
|
||||
if playlist.is_endlist:
|
||||
logger.debug('playlist ended')
|
||||
self._run_coroutine(self._live.update_room_info())
|
||||
if not self._live.is_living():
|
||||
self._stopped = True
|
||||
break
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
@ -338,7 +290,7 @@ class HLSStreamRecorder(
|
||||
break
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
logger.warning(repr(e))
|
||||
self._connection_recovered.wait()
|
||||
self._wait_for_connection_error()
|
||||
except RetryError as e:
|
||||
logger.warning(repr(e))
|
||||
break
|
||||
@ -434,6 +386,7 @@ class HLSStreamRecorder(
|
||||
desc='Recording',
|
||||
unit='B',
|
||||
unit_scale=True,
|
||||
unit_divisor=1024,
|
||||
postfix=self._make_pbar_postfix(),
|
||||
) as progress_bar:
|
||||
self._progress_bar = progress_bar
|
@ -12,9 +12,7 @@ from tenacity import (
|
||||
)
|
||||
|
||||
from .raw_danmaku_receiver import RawDanmakuReceiver
|
||||
from .base_stream_recorder import (
|
||||
BaseStreamRecorder, StreamRecorderEventListener
|
||||
)
|
||||
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
||||
from ..bili.live import Live
|
||||
from ..exception import exception_callback, submit_exception
|
||||
from ..event.event_emitter import EventListener, EventEmitter
|
||||
@ -45,7 +43,7 @@ class RawDanmakuDumper(
|
||||
def __init__(
|
||||
self,
|
||||
live: Live,
|
||||
stream_recorder: BaseStreamRecorder,
|
||||
stream_recorder: StreamRecorder,
|
||||
danmaku_receiver: RawDanmakuReceiver,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
@ -53,13 +51,7 @@ class RawDanmakuDumper(
|
||||
self._stream_recorder = stream_recorder
|
||||
self._receiver = danmaku_receiver
|
||||
|
||||
def change_stream_recorder(
|
||||
self, stream_recorder: BaseStreamRecorder
|
||||
) -> None:
|
||||
self._stream_recorder.remove_listener(self)
|
||||
self._stream_recorder = stream_recorder
|
||||
self._stream_recorder.add_listener(self)
|
||||
logger.debug('Changed stream recorder')
|
||||
self._lock: asyncio.Lock = asyncio.Lock()
|
||||
|
||||
def _do_enable(self) -> None:
|
||||
self._stream_recorder.add_listener(self)
|
||||
@ -72,10 +64,12 @@ class RawDanmakuDumper(
|
||||
async def on_video_file_created(
|
||||
self, video_path: str, record_start_time: int
|
||||
) -> None:
|
||||
async with self._lock:
|
||||
self._path = raw_danmaku_path(video_path)
|
||||
self._start_dumping()
|
||||
|
||||
async def on_video_file_completed(self, video_path: str) -> None:
|
||||
async with self._lock:
|
||||
await self._stop_dumping()
|
||||
|
||||
def _start_dumping(self) -> None:
|
||||
|
@ -1,35 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Iterator, Optional, Type
|
||||
from typing import Iterator, Optional
|
||||
|
||||
import aiohttp
|
||||
import aiofiles
|
||||
import humanize
|
||||
from tenacity import retry, wait_fixed, stop_after_attempt
|
||||
|
||||
from .danmaku_receiver import DanmakuReceiver
|
||||
from .danmaku_dumper import DanmakuDumper, DanmakuDumperEventListener
|
||||
from .raw_danmaku_receiver import RawDanmakuReceiver
|
||||
from .raw_danmaku_dumper import RawDanmakuDumper, RawDanmakuDumperEventListener
|
||||
from .base_stream_recorder import (
|
||||
BaseStreamRecorder, StreamRecorderEventListener
|
||||
)
|
||||
from .stream_analyzer import StreamProfile
|
||||
from .flv_stream_recorder import FLVStreamRecorder
|
||||
from .hls_stream_recorder import HLSStreamRecorder
|
||||
from ..event.event_emitter import EventListener, EventEmitter
|
||||
from ..flv.data_analyser import MetaData
|
||||
from ..bili.live import Live
|
||||
from ..bili.models import RoomInfo
|
||||
from ..bili.danmaku_client import DanmakuClient
|
||||
from ..bili.live_monitor import LiveMonitor, LiveEventListener
|
||||
from ..bili.typing import StreamFormat, QualityNumber
|
||||
from ..utils.mixins import AsyncStoppableMixin
|
||||
from ..path import cover_path
|
||||
from ..bili.live import Live
|
||||
from ..bili.live_monitor import LiveEventListener, LiveMonitor
|
||||
from ..bili.models import RoomInfo
|
||||
from ..bili.typing import QualityNumber, StreamFormat
|
||||
from ..event.event_emitter import EventEmitter, EventListener
|
||||
from ..flv.data_analyser import MetaData
|
||||
from ..logging.room_id import aio_task_with_room_id
|
||||
|
||||
from ..utils.mixins import AsyncStoppableMixin
|
||||
from .cover_downloader import CoverDownloader, CoverSaveStrategy
|
||||
from .danmaku_dumper import DanmakuDumper, DanmakuDumperEventListener
|
||||
from .danmaku_receiver import DanmakuReceiver
|
||||
from .raw_danmaku_dumper import RawDanmakuDumper, RawDanmakuDumperEventListener
|
||||
from .raw_danmaku_receiver import RawDanmakuReceiver
|
||||
from .stream_analyzer import StreamProfile
|
||||
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
||||
|
||||
__all__ = 'RecorderEventListener', 'Recorder'
|
||||
|
||||
@ -47,29 +40,19 @@ class RecorderEventListener(EventListener):
|
||||
async def on_recording_cancelled(self, recorder: Recorder) -> None:
|
||||
...
|
||||
|
||||
async def on_video_file_created(
|
||||
self, recorder: Recorder, path: str
|
||||
) -> None:
|
||||
async def on_video_file_created(self, recorder: Recorder, path: str) -> None:
|
||||
...
|
||||
|
||||
async def on_video_file_completed(
|
||||
self, recorder: Recorder, path: str
|
||||
) -> None:
|
||||
async def on_video_file_completed(self, recorder: Recorder, path: str) -> None:
|
||||
...
|
||||
|
||||
async def on_danmaku_file_created(
|
||||
self, recorder: Recorder, path: str
|
||||
) -> None:
|
||||
async def on_danmaku_file_created(self, recorder: Recorder, path: str) -> None:
|
||||
...
|
||||
|
||||
async def on_danmaku_file_completed(
|
||||
self, recorder: Recorder, path: str
|
||||
) -> None:
|
||||
async def on_danmaku_file_completed(self, recorder: Recorder, path: str) -> None:
|
||||
...
|
||||
|
||||
async def on_raw_danmaku_file_created(
|
||||
self, recorder: Recorder, path: str
|
||||
) -> None:
|
||||
async def on_raw_danmaku_file_created(self, recorder: Recorder, path: str) -> None:
|
||||
...
|
||||
|
||||
async def on_raw_danmaku_file_completed(
|
||||
@ -96,6 +79,7 @@ class Recorder(
|
||||
*,
|
||||
stream_format: StreamFormat = 'flv',
|
||||
quality_number: QualityNumber = 10000,
|
||||
fmp4_stream_timeout: int = 10,
|
||||
buffer_size: Optional[int] = None,
|
||||
read_timeout: Optional[int] = None,
|
||||
disconnection_timeout: Optional[int] = None,
|
||||
@ -107,6 +91,7 @@ class Recorder(
|
||||
record_guard_buy: bool = False,
|
||||
record_super_chat: bool = False,
|
||||
save_cover: bool = False,
|
||||
cover_save_strategy: CoverSaveStrategy = CoverSaveStrategy.DEFAULT,
|
||||
save_raw_danmaku: bool = False,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
@ -114,23 +99,18 @@ class Recorder(
|
||||
self._live = live
|
||||
self._danmaku_client = danmaku_client
|
||||
self._live_monitor = live_monitor
|
||||
self.save_cover = save_cover
|
||||
self.save_raw_danmaku = save_raw_danmaku
|
||||
|
||||
self._recording: bool = False
|
||||
self._stream_available: bool = False
|
||||
|
||||
cls: Type[BaseStreamRecorder]
|
||||
if stream_format == 'flv':
|
||||
cls = FLVStreamRecorder
|
||||
else:
|
||||
cls = HLSStreamRecorder
|
||||
self._stream_recorder = cls(
|
||||
self._stream_recorder = StreamRecorder(
|
||||
self._live,
|
||||
out_dir=out_dir,
|
||||
path_template=path_template,
|
||||
stream_format=stream_format,
|
||||
quality_number=quality_number,
|
||||
fmp4_stream_timeout=fmp4_stream_timeout,
|
||||
buffer_size=buffer_size,
|
||||
read_timeout=read_timeout,
|
||||
disconnection_timeout=disconnection_timeout,
|
||||
@ -151,9 +131,14 @@ class Recorder(
|
||||
)
|
||||
self._raw_danmaku_receiver = RawDanmakuReceiver(danmaku_client)
|
||||
self._raw_danmaku_dumper = RawDanmakuDumper(
|
||||
self._live, self._stream_recorder, self._raw_danmaku_receiver
|
||||
)
|
||||
|
||||
self._cover_downloader = CoverDownloader(
|
||||
self._live,
|
||||
self._stream_recorder,
|
||||
self._raw_danmaku_receiver,
|
||||
save_cover=save_cover,
|
||||
cover_save_strategy=cover_save_strategy,
|
||||
)
|
||||
|
||||
@property
|
||||
@ -181,11 +166,19 @@ class Recorder(
|
||||
self._stream_recorder.quality_number = value
|
||||
|
||||
@property
|
||||
def real_stream_format(self) -> StreamFormat:
|
||||
def fmp4_stream_timeout(self) -> int:
|
||||
return self._stream_recorder.fmp4_stream_timeout
|
||||
|
||||
@fmp4_stream_timeout.setter
|
||||
def fmp4_stream_timeout(self, value: int) -> None:
|
||||
self._stream_recorder.fmp4_stream_timeout = value
|
||||
|
||||
@property
|
||||
def real_stream_format(self) -> Optional[StreamFormat]:
|
||||
return self._stream_recorder.real_stream_format
|
||||
|
||||
@property
|
||||
def real_quality_number(self) -> QualityNumber:
|
||||
def real_quality_number(self) -> Optional[QualityNumber]:
|
||||
return self._stream_recorder.real_quality_number
|
||||
|
||||
@property
|
||||
@ -252,6 +245,22 @@ class Recorder(
|
||||
def record_super_chat(self, value: bool) -> None:
|
||||
self._danmaku_dumper.record_super_chat = value
|
||||
|
||||
@property
|
||||
def save_cover(self) -> bool:
|
||||
return self._cover_downloader.save_cover
|
||||
|
||||
@save_cover.setter
|
||||
def save_cover(self, value: bool) -> None:
|
||||
self._cover_downloader.save_cover = value
|
||||
|
||||
@property
|
||||
def cover_save_strategy(self) -> CoverSaveStrategy:
|
||||
return self._cover_downloader.cover_save_strategy
|
||||
|
||||
@cover_save_strategy.setter
|
||||
def cover_save_strategy(self, value: CoverSaveStrategy) -> None:
|
||||
self._cover_downloader.cover_save_strategy = value
|
||||
|
||||
@property
|
||||
def stream_url(self) -> str:
|
||||
return self._stream_recorder.stream_url
|
||||
@ -375,15 +384,11 @@ class Recorder(
|
||||
self._print_changed_room_info(room_info)
|
||||
self._stream_recorder.update_progress_bar_info()
|
||||
|
||||
async def on_video_file_created(
|
||||
self, path: str, record_start_time: int
|
||||
) -> None:
|
||||
async def on_video_file_created(self, path: str, record_start_time: int) -> None:
|
||||
await self._emit('video_file_created', self, path)
|
||||
|
||||
async def on_video_file_completed(self, path: str) -> None:
|
||||
await self._emit('video_file_completed', self, path)
|
||||
if self.save_cover:
|
||||
await self._save_cover_image(path)
|
||||
|
||||
async def on_danmaku_file_created(self, path: str) -> None:
|
||||
await self._emit('danmaku_file_created', self, path)
|
||||
@ -426,7 +431,6 @@ class Recorder(
|
||||
async def _start_recording(self) -> None:
|
||||
if self._recording:
|
||||
return
|
||||
self._change_stream_recorder()
|
||||
self._recording = True
|
||||
|
||||
if self.save_raw_danmaku:
|
||||
@ -434,6 +438,7 @@ class Recorder(
|
||||
self._raw_danmaku_receiver.start()
|
||||
self._danmaku_dumper.enable()
|
||||
self._danmaku_receiver.start()
|
||||
self._cover_downloader.enable()
|
||||
|
||||
await self._prepare()
|
||||
if self._stream_available:
|
||||
@ -455,6 +460,7 @@ class Recorder(
|
||||
self._raw_danmaku_receiver.stop()
|
||||
self._danmaku_dumper.disable()
|
||||
self._danmaku_receiver.stop()
|
||||
self._cover_downloader.disable()
|
||||
|
||||
if self._stopped:
|
||||
logger.info('Recording Cancelled')
|
||||
@ -492,60 +498,6 @@ class Recorder(
|
||||
if not self._stream_recorder.stopped:
|
||||
await self.stop()
|
||||
|
||||
def _change_stream_recorder(self) -> None:
|
||||
if self._recording:
|
||||
logger.debug('Can not change stream recorder while recording')
|
||||
return
|
||||
|
||||
cls: Type[BaseStreamRecorder]
|
||||
if self.stream_format == 'flv':
|
||||
cls = FLVStreamRecorder
|
||||
else:
|
||||
cls = HLSStreamRecorder
|
||||
|
||||
if self._stream_recorder.__class__ == cls:
|
||||
return
|
||||
|
||||
self._stream_recorder.remove_listener(self)
|
||||
self._stream_recorder = cls(
|
||||
self._live,
|
||||
out_dir=self.out_dir,
|
||||
path_template=self.path_template,
|
||||
stream_format=self.stream_format,
|
||||
quality_number=self.quality_number,
|
||||
buffer_size=self.buffer_size,
|
||||
read_timeout=self.read_timeout,
|
||||
disconnection_timeout=self.disconnection_timeout,
|
||||
filesize_limit=self.filesize_limit,
|
||||
duration_limit=self.duration_limit,
|
||||
)
|
||||
self._stream_recorder.add_listener(self)
|
||||
|
||||
self._danmaku_dumper.change_stream_recorder(self._stream_recorder)
|
||||
self._raw_danmaku_dumper.change_stream_recorder(self._stream_recorder)
|
||||
|
||||
logger.debug(f'Changed stream recorder to {cls.__name__}')
|
||||
|
||||
@aio_task_with_room_id
|
||||
async def _save_cover_image(self, video_path: str) -> None:
|
||||
try:
|
||||
await self._live.update_info()
|
||||
url = self._live.room_info.cover
|
||||
ext = url.rsplit('.', 1)[-1]
|
||||
path = cover_path(video_path, ext)
|
||||
await self._save_file(url, path)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to save cover image: {repr(e)}')
|
||||
else:
|
||||
logger.info(f'Saved cover image: {path}')
|
||||
|
||||
@retry(reraise=True, wait=wait_fixed(1), stop=stop_after_attempt(3))
|
||||
async def _save_file(self, url: str, path: str) -> None:
|
||||
async with aiohttp.ClientSession(raise_for_status=True) as session:
|
||||
async with session.get(url) as response:
|
||||
async with aiofiles.open(path, 'wb') as file:
|
||||
await file.write(await response.read())
|
||||
|
||||
def _print_waiting_message(self) -> None:
|
||||
logger.info('Waiting... until the live starts')
|
||||
|
||||
@ -554,9 +506,7 @@ class Recorder(
|
||||
user_info = self._live.user_info
|
||||
|
||||
if room_info.live_start_time > 0:
|
||||
live_start_time = str(
|
||||
datetime.fromtimestamp(room_info.live_start_time)
|
||||
)
|
||||
live_start_time = str(datetime.fromtimestamp(room_info.live_start_time))
|
||||
else:
|
||||
live_start_time = 'NULL'
|
||||
|
||||
|
284
src/blrec/core/stream_recorder.py
Normal file
284
src/blrec/core/stream_recorder.py
Normal file
@ -0,0 +1,284 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Iterator, Optional
|
||||
|
||||
from ..bili.live import Live
|
||||
from ..bili.typing import QualityNumber, StreamFormat
|
||||
from ..event.event_emitter import EventEmitter
|
||||
from ..flv.data_analyser import MetaData
|
||||
from ..utils.mixins import AsyncStoppableMixin
|
||||
from .flv_stream_recorder_impl import FLVStreamRecorderImpl
|
||||
from .hls_stream_recorder_impl import HLSStreamRecorderImpl
|
||||
from .stream_analyzer import StreamProfile
|
||||
from .stream_recorder_impl import StreamRecorderEventListener
|
||||
|
||||
__all__ = 'StreamRecorder', 'StreamRecorderEventListener'
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StreamRecorder(
|
||||
StreamRecorderEventListener,
|
||||
EventEmitter[StreamRecorderEventListener],
|
||||
AsyncStoppableMixin,
|
||||
):
|
||||
def __init__(
|
||||
self,
|
||||
live: Live,
|
||||
out_dir: str,
|
||||
path_template: str,
|
||||
*,
|
||||
stream_format: StreamFormat = 'flv',
|
||||
quality_number: QualityNumber = 10000,
|
||||
fmp4_stream_timeout: int = 10,
|
||||
buffer_size: Optional[int] = None,
|
||||
read_timeout: Optional[int] = None,
|
||||
disconnection_timeout: Optional[int] = None,
|
||||
filesize_limit: int = 0,
|
||||
duration_limit: int = 0,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.stream_format = stream_format
|
||||
self.fmp4_stream_timeout = fmp4_stream_timeout
|
||||
|
||||
if stream_format == 'flv':
|
||||
cls = FLVStreamRecorderImpl
|
||||
elif stream_format == 'fmp4':
|
||||
cls = HLSStreamRecorderImpl # type: ignore
|
||||
else:
|
||||
logger.warning(
|
||||
f'The specified stream format ({stream_format}) is '
|
||||
'unsupported, will using the stream format (flv) instead.'
|
||||
)
|
||||
self.stream_format = 'flv'
|
||||
cls = FLVStreamRecorderImpl
|
||||
|
||||
self._impl = cls(
|
||||
live=live,
|
||||
out_dir=out_dir,
|
||||
path_template=path_template,
|
||||
quality_number=quality_number,
|
||||
buffer_size=buffer_size,
|
||||
read_timeout=read_timeout,
|
||||
disconnection_timeout=disconnection_timeout,
|
||||
filesize_limit=filesize_limit,
|
||||
duration_limit=duration_limit,
|
||||
)
|
||||
|
||||
self._impl.add_listener(self)
|
||||
|
||||
@property
|
||||
def stream_url(self) -> str:
|
||||
return self._impl.stream_url
|
||||
|
||||
@property
|
||||
def stream_host(self) -> str:
|
||||
return self._impl.stream_host
|
||||
|
||||
@property
|
||||
def dl_total(self) -> int:
|
||||
return self._impl.dl_total
|
||||
|
||||
@property
|
||||
def dl_rate(self) -> float:
|
||||
return self._impl.dl_rate
|
||||
|
||||
@property
|
||||
def rec_elapsed(self) -> float:
|
||||
return self._impl.rec_elapsed
|
||||
|
||||
@property
|
||||
def rec_total(self) -> int:
|
||||
return self._impl.rec_total
|
||||
|
||||
@property
|
||||
def rec_rate(self) -> float:
|
||||
return self._impl.rec_rate
|
||||
|
||||
@property
|
||||
def out_dir(self) -> str:
|
||||
return self._impl.out_dir
|
||||
|
||||
@out_dir.setter
|
||||
def out_dir(self, value: str) -> None:
|
||||
self._impl.out_dir = value
|
||||
|
||||
@property
|
||||
def path_template(self) -> str:
|
||||
return self._impl.path_template
|
||||
|
||||
@path_template.setter
|
||||
def path_template(self, value: str) -> None:
|
||||
self._impl.path_template = value
|
||||
|
||||
@property
|
||||
def quality_number(self) -> QualityNumber:
|
||||
return self._impl.quality_number
|
||||
|
||||
@quality_number.setter
|
||||
def quality_number(self, value: QualityNumber) -> None:
|
||||
self._impl.quality_number = value
|
||||
|
||||
@property
|
||||
def real_stream_format(self) -> Optional[StreamFormat]:
|
||||
if self.stopped:
|
||||
return None
|
||||
return self._impl.stream_format
|
||||
|
||||
@property
|
||||
def real_quality_number(self) -> Optional[QualityNumber]:
|
||||
return self._impl.real_quality_number
|
||||
|
||||
@property
|
||||
def buffer_size(self) -> int:
|
||||
return self._impl.buffer_size
|
||||
|
||||
@buffer_size.setter
|
||||
def buffer_size(self, value: int) -> None:
|
||||
self._impl.buffer_size = value
|
||||
|
||||
@property
|
||||
def read_timeout(self) -> int:
|
||||
return self._impl.read_timeout
|
||||
|
||||
@read_timeout.setter
|
||||
def read_timeout(self, value: int) -> None:
|
||||
self._impl.read_timeout = value
|
||||
|
||||
@property
|
||||
def disconnection_timeout(self) -> int:
|
||||
return self._impl.disconnection_timeout
|
||||
|
||||
@disconnection_timeout.setter
|
||||
def disconnection_timeout(self, value: int) -> None:
|
||||
self._impl.disconnection_timeout = value
|
||||
|
||||
@property
|
||||
def filesize_limit(self) -> int:
|
||||
return self._impl.filesize_limit
|
||||
|
||||
@filesize_limit.setter
|
||||
def filesize_limit(self, value: int) -> None:
|
||||
self._impl.filesize_limit = value
|
||||
|
||||
@property
|
||||
def duration_limit(self) -> int:
|
||||
return self._impl.duration_limit
|
||||
|
||||
@duration_limit.setter
|
||||
def duration_limit(self, value: int) -> None:
|
||||
self._impl.duration_limit = value
|
||||
|
||||
@property
|
||||
def recording_path(self) -> Optional[str]:
|
||||
return self._impl.recording_path
|
||||
|
||||
@property
|
||||
def metadata(self) -> Optional[MetaData]:
|
||||
return self._impl.metadata
|
||||
|
||||
@property
|
||||
def stream_profile(self) -> StreamProfile:
|
||||
return self._impl.stream_profile
|
||||
|
||||
def has_file(self) -> bool:
|
||||
return self._impl.has_file()
|
||||
|
||||
def get_files(self) -> Iterator[str]:
|
||||
yield from self._impl.get_files()
|
||||
|
||||
def clear_files(self) -> None:
|
||||
self._impl.clear_files()
|
||||
|
||||
def can_cut_stream(self) -> bool:
|
||||
return self._impl.can_cut_stream()
|
||||
|
||||
def cut_stream(self) -> bool:
|
||||
return self._impl.cut_stream()
|
||||
|
||||
def update_progress_bar_info(self) -> None:
|
||||
self._impl.update_progress_bar_info()
|
||||
|
||||
@property
|
||||
def stopped(self) -> bool:
|
||||
return self._impl.stopped
|
||||
|
||||
async def _do_start(self) -> None:
|
||||
stream_format = self.stream_format
|
||||
if stream_format == 'fmp4':
|
||||
logger.info('Waiting for the fmp4 stream becomes available...')
|
||||
available = await self._wait_fmp4_stream()
|
||||
if not available:
|
||||
logger.warning(
|
||||
'The specified stream format (fmp4) is not available '
|
||||
f'in {self.fmp4_stream_timeout} seconcds, '
|
||||
'falling back to stream format (flv).'
|
||||
)
|
||||
stream_format = 'flv'
|
||||
self._change_impl(stream_format)
|
||||
await self._impl.start()
|
||||
|
||||
async def _do_stop(self) -> None:
|
||||
await self._impl.stop()
|
||||
|
||||
async def on_video_file_created(self, path: str, record_start_time: int) -> None:
|
||||
await self._emit('video_file_created', path, record_start_time)
|
||||
|
||||
async def on_video_file_completed(self, path: str) -> None:
|
||||
await self._emit('video_file_completed', path)
|
||||
|
||||
async def on_stream_recording_stopped(self) -> None:
|
||||
await self._emit('stream_recording_stopped')
|
||||
|
||||
async def _wait_fmp4_stream(self) -> bool:
|
||||
end_time = time.monotonic() + self.fmp4_stream_timeout
|
||||
available = False # debounce
|
||||
while True:
|
||||
try:
|
||||
await self._impl._live.get_live_stream_urls(stream_format='fmp4')
|
||||
except Exception:
|
||||
available = False
|
||||
if time.monotonic() > end_time:
|
||||
return False
|
||||
else:
|
||||
if available:
|
||||
return True
|
||||
else:
|
||||
available = True
|
||||
await asyncio.sleep(1)
|
||||
|
||||
def _change_impl(self, stream_format: StreamFormat) -> None:
|
||||
if stream_format == 'flv':
|
||||
cls = FLVStreamRecorderImpl
|
||||
elif stream_format == 'fmp4':
|
||||
cls = HLSStreamRecorderImpl # type: ignore
|
||||
else:
|
||||
logger.warning(
|
||||
f'The specified stream format ({stream_format}) is '
|
||||
'unsupported, will using the stream format (flv) instead.'
|
||||
)
|
||||
cls = FLVStreamRecorderImpl
|
||||
|
||||
if self._impl.__class__ == cls:
|
||||
return
|
||||
|
||||
self._impl.remove_listener(self)
|
||||
|
||||
self._impl = cls(
|
||||
live=self._impl._live,
|
||||
out_dir=self._impl.out_dir,
|
||||
path_template=self._impl.path_template,
|
||||
quality_number=self._impl.quality_number,
|
||||
buffer_size=self._impl.buffer_size,
|
||||
read_timeout=self._impl.read_timeout,
|
||||
disconnection_timeout=self._impl.disconnection_timeout,
|
||||
filesize_limit=self._impl.filesize_limit,
|
||||
duration_limit=self._impl.duration_limit,
|
||||
)
|
||||
|
||||
self._impl.add_listener(self)
|
||||
|
||||
logger.debug(f'Changed stream recorder impl to {cls.__name__}')
|
@ -1,53 +1,61 @@
|
||||
import asyncio
|
||||
import errno
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from threading import Thread, Event
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from collections import OrderedDict
|
||||
|
||||
from typing import Any, BinaryIO, Dict, Iterator, Optional, Tuple
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from threading import Thread
|
||||
from typing import Any, BinaryIO, Dict, Final, Iterator, Optional, Tuple
|
||||
|
||||
import aiohttp
|
||||
import urllib3
|
||||
from tqdm import tqdm
|
||||
from rx.subject import Subject
|
||||
from rx.core import Observable
|
||||
from rx.subject import Subject
|
||||
from tenacity import (
|
||||
Retrying,
|
||||
TryAgain,
|
||||
retry,
|
||||
wait_none,
|
||||
wait_fixed,
|
||||
retry_if_exception_type,
|
||||
retry_if_not_exception_type,
|
||||
retry_if_result,
|
||||
stop_after_attempt,
|
||||
stop_after_delay,
|
||||
wait_chain,
|
||||
wait_exponential,
|
||||
stop_after_delay,
|
||||
stop_after_attempt,
|
||||
retry_if_exception_type,
|
||||
TryAgain,
|
||||
wait_fixed,
|
||||
wait_none,
|
||||
)
|
||||
from tqdm import tqdm
|
||||
|
||||
from .. import __version__, __prog__, __github__
|
||||
from .stream_remuxer import StreamRemuxer
|
||||
from .stream_analyzer import StreamProfile
|
||||
from .statistics import StatisticsCalculator
|
||||
from ..event.event_emitter import EventListener, EventEmitter
|
||||
from ..bili.live import Live
|
||||
from ..bili.typing import ApiPlatform, StreamFormat, QualityNumber
|
||||
from .. import __github__, __prog__, __version__
|
||||
from ..bili.exceptions import (
|
||||
LiveRoomEncrypted,
|
||||
LiveRoomHidden,
|
||||
LiveRoomLocked,
|
||||
NoStreamAvailable,
|
||||
NoStreamFormatAvailable,
|
||||
NoStreamQualityAvailable,
|
||||
)
|
||||
from ..bili.helpers import get_quality_name
|
||||
from ..bili.live import Live
|
||||
from ..bili.typing import ApiPlatform, QualityNumber, StreamFormat
|
||||
from ..event.event_emitter import EventEmitter, EventListener
|
||||
from ..flv.data_analyser import MetaData
|
||||
from ..flv.stream_processor import StreamProcessor, BaseOutputFileManager
|
||||
from ..flv.stream_processor import BaseOutputFileManager, StreamProcessor
|
||||
from ..logging.room_id import aio_task_with_room_id
|
||||
from ..path import escape_path
|
||||
from ..utils.io import wait_for
|
||||
from ..utils.mixins import AsyncCooperationMixin, AsyncStoppableMixin
|
||||
from ..path import escape_path
|
||||
from ..logging.room_id import aio_task_with_room_id
|
||||
from ..bili.exceptions import (
|
||||
NoStreamFormatAvailable, NoStreamCodecAvailable, NoStreamQualityAvailable,
|
||||
)
|
||||
from .retry import before_sleep_log, wait_exponential_for_same_exceptions
|
||||
from .statistics import StatisticsCalculator
|
||||
from .stream_analyzer import StreamProfile
|
||||
from .stream_remuxer import StreamRemuxer
|
||||
|
||||
|
||||
__all__ = 'BaseStreamRecorder', 'StreamRecorderEventListener', 'StreamProxy'
|
||||
__all__ = 'StreamRecorderImpl',
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -67,7 +75,7 @@ class StreamRecorderEventListener(EventListener):
|
||||
...
|
||||
|
||||
|
||||
class BaseStreamRecorder(
|
||||
class StreamRecorderImpl(
|
||||
EventEmitter[StreamRecorderEventListener],
|
||||
AsyncCooperationMixin,
|
||||
AsyncStoppableMixin,
|
||||
@ -99,9 +107,8 @@ class BaseStreamRecorder(
|
||||
live, out_dir, path_template, buffer_size
|
||||
)
|
||||
|
||||
self._stream_format = stream_format
|
||||
self._stream_format: Final = stream_format
|
||||
self._quality_number = quality_number
|
||||
self._real_stream_format: Optional[StreamFormat] = None
|
||||
self._real_quality_number: Optional[QualityNumber] = None
|
||||
self._api_platform: ApiPlatform = 'android'
|
||||
self._use_alternative_stream: bool = False
|
||||
@ -116,8 +123,6 @@ class BaseStreamRecorder(
|
||||
self._stream_host: str = ''
|
||||
self._stream_profile: StreamProfile = {}
|
||||
|
||||
self._connection_recovered = Event()
|
||||
|
||||
def on_file_created(args: Tuple[str, int]) -> None:
|
||||
logger.info(f"Video file created: '{args[0]}'")
|
||||
self._emit_event('video_file_created', *args)
|
||||
@ -177,11 +182,6 @@ class BaseStreamRecorder(
|
||||
def stream_format(self) -> StreamFormat:
|
||||
return self._stream_format
|
||||
|
||||
@stream_format.setter
|
||||
def stream_format(self, value: StreamFormat) -> None:
|
||||
self._stream_format = value
|
||||
self._real_stream_format = None
|
||||
|
||||
@property
|
||||
def quality_number(self) -> QualityNumber:
|
||||
return self._quality_number
|
||||
@ -189,15 +189,12 @@ class BaseStreamRecorder(
|
||||
@quality_number.setter
|
||||
def quality_number(self, value: QualityNumber) -> None:
|
||||
self._quality_number = value
|
||||
self._real_quality_number = None
|
||||
|
||||
@property
|
||||
def real_stream_format(self) -> StreamFormat:
|
||||
return self._real_stream_format or self.stream_format
|
||||
|
||||
@property
|
||||
def real_quality_number(self) -> QualityNumber:
|
||||
return self._real_quality_number or self.quality_number
|
||||
def real_quality_number(self) -> Optional[QualityNumber]:
|
||||
if self.stopped:
|
||||
return None
|
||||
return self._real_quality_number
|
||||
|
||||
@property
|
||||
def filesize_limit(self) -> int:
|
||||
@ -263,16 +260,20 @@ class BaseStreamRecorder(
|
||||
if self._progress_bar is not None:
|
||||
self._progress_bar.set_postfix_str(self._make_pbar_postfix())
|
||||
|
||||
async def _do_start(self) -> None:
|
||||
logger.debug('Starting stream recorder...')
|
||||
def _reset(self) -> None:
|
||||
self._dl_calculator.reset()
|
||||
self._rec_calculator.reset()
|
||||
self._stream_url = ''
|
||||
self._stream_host = ''
|
||||
self._stream_profile = {}
|
||||
self._api_platform = 'android'
|
||||
self._real_quality_number = None
|
||||
self._use_alternative_stream = False
|
||||
self._connection_recovered.clear()
|
||||
self._fall_back_stream_format = False
|
||||
|
||||
async def _do_start(self) -> None:
|
||||
logger.debug('Starting stream recorder...')
|
||||
self._reset()
|
||||
self._thread = Thread(
|
||||
target=self._run, name=f'StreamRecorder::{self._live.room_id}'
|
||||
)
|
||||
@ -290,6 +291,44 @@ class BaseStreamRecorder(
|
||||
def _run(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
def _streaming_loop(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
def _main_loop(self) -> None:
|
||||
for attempt in Retrying(
|
||||
reraise=True,
|
||||
retry=(
|
||||
retry_if_result(lambda r: not self._stopped) |
|
||||
retry_if_not_exception_type((NotImplementedError))
|
||||
),
|
||||
wait=wait_exponential_for_same_exceptions(max=60),
|
||||
before_sleep=before_sleep_log(logger, logging.DEBUG, 'main_loop'),
|
||||
):
|
||||
with attempt:
|
||||
try:
|
||||
self._streaming_loop()
|
||||
except (NoStreamAvailable, NoStreamFormatAvailable) as e:
|
||||
logger.warning(f'Failed to get live stream url: {repr(e)}')
|
||||
except OSError as e:
|
||||
logger.critical(repr(e), exc_info=e)
|
||||
if e.errno == errno.ENOSPC:
|
||||
# OSError(28, 'No space left on device')
|
||||
self._handle_exception(e)
|
||||
self._stopped = True
|
||||
except LiveRoomHidden:
|
||||
logger.error('The live room has been hidden!')
|
||||
self._stopped = True
|
||||
except LiveRoomLocked:
|
||||
logger.error('The live room has been locked!')
|
||||
self._stopped = True
|
||||
except LiveRoomEncrypted:
|
||||
logger.error('The live room has been encrypted!')
|
||||
self._stopped = True
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
self._handle_exception(e)
|
||||
|
||||
def _rotate_api_platform(self) -> None:
|
||||
if self._api_platform == 'android':
|
||||
self._api_platform = 'web'
|
||||
@ -305,8 +344,8 @@ class BaseStreamRecorder(
|
||||
stop=stop_after_attempt(300),
|
||||
)
|
||||
def _get_live_stream_url(self) -> str:
|
||||
fmt = self._stream_format
|
||||
qn = self._real_quality_number or self.quality_number
|
||||
fmt = self._real_stream_format or self.stream_format
|
||||
logger.info(
|
||||
f'Getting the live stream url... qn: {qn}, format: {fmt}, '
|
||||
f'api platform: {self._api_platform}, '
|
||||
@ -327,35 +366,11 @@ class BaseStreamRecorder(
|
||||
)
|
||||
self._real_quality_number = 10000
|
||||
raise TryAgain
|
||||
except NoStreamFormatAvailable:
|
||||
if fmt == 'fmp4':
|
||||
logger.info(
|
||||
'The specified stream format (fmp4) is not available, '
|
||||
'falling back to stream format (ts).'
|
||||
)
|
||||
self._real_stream_format = 'ts'
|
||||
elif fmt == 'ts':
|
||||
logger.info(
|
||||
'The specified stream format (ts) is not available, '
|
||||
'falling back to stream format (flv).'
|
||||
)
|
||||
self._real_stream_format = 'flv'
|
||||
else:
|
||||
raise NotImplementedError(fmt)
|
||||
raise TryAgain
|
||||
except NoStreamCodecAvailable as e:
|
||||
logger.warning(repr(e))
|
||||
raise TryAgain
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get live stream urls: {repr(e)}')
|
||||
self._rotate_api_platform()
|
||||
raise TryAgain
|
||||
else:
|
||||
logger.info(
|
||||
f'Adopted the stream format ({fmt}) and quality ({qn})'
|
||||
)
|
||||
self._real_quality_number = qn
|
||||
self._real_stream_format = fmt
|
||||
|
||||
if not self._use_alternative_stream:
|
||||
url = urls[0]
|
||||
@ -366,8 +381,9 @@ class BaseStreamRecorder(
|
||||
self._use_alternative_stream = False
|
||||
self._rotate_api_platform()
|
||||
logger.info(
|
||||
'No alternative stream url available, will using the primary'
|
||||
f' stream url from {self._api_platform} api instead.'
|
||||
'No alternative stream url available, '
|
||||
'will using the primary stream url '
|
||||
f'from {self._api_platform} api instead.'
|
||||
)
|
||||
raise TryAgain
|
||||
logger.info(f"Got live stream url: '{url}'")
|
||||
@ -380,16 +396,7 @@ class BaseStreamRecorder(
|
||||
logger.debug(f'Retry {name} after {seconds} seconds')
|
||||
time.sleep(seconds)
|
||||
|
||||
def _wait_for_connection_error(self) -> None:
|
||||
Thread(
|
||||
target=self._conectivity_checker,
|
||||
name=f'ConectivityChecker::{self._live.room_id}',
|
||||
daemon=True,
|
||||
).start()
|
||||
self._connection_recovered.wait()
|
||||
self._connection_recovered.clear()
|
||||
|
||||
def _conectivity_checker(self, check_interval: int = 3) -> None:
|
||||
def _wait_for_connection_error(self, check_interval: int = 3) -> None:
|
||||
timeout = self.disconnection_timeout
|
||||
logger.info(f'Waiting {timeout} seconds for connection recovery... ')
|
||||
timebase = time.monotonic()
|
||||
@ -397,11 +404,10 @@ class BaseStreamRecorder(
|
||||
if timeout is not None and time.monotonic() - timebase > timeout:
|
||||
logger.error(f'Connection not recovered in {timeout} seconds')
|
||||
self._stopped = True
|
||||
self._connection_recovered.set()
|
||||
break
|
||||
time.sleep(check_interval)
|
||||
else:
|
||||
logger.info('Connection recovered')
|
||||
self._connection_recovered.set()
|
||||
|
||||
def _make_pbar_postfix(self) -> str:
|
||||
return '{room_id} - {user_name}: {room_title}'.format(
|
||||
@ -434,7 +440,7 @@ B站直播录像
|
||||
房间号:{self._live.room_info.room_id}
|
||||
开播时间:{live_start_time}
|
||||
流主机: {self._stream_host}
|
||||
流格式:{self._real_stream_format}
|
||||
流格式:{self._stream_format}
|
||||
流画质:{stream_quality}
|
||||
录制程序:{__prog__} v{__version__} {__github__}''',
|
||||
'description': OrderedDict({
|
||||
@ -446,7 +452,7 @@ B站直播录像
|
||||
'ParentArea': self._live.room_info.parent_area_name,
|
||||
'LiveStartTime': str(live_start_time),
|
||||
'StreamHost': self._stream_host,
|
||||
'StreamFormat': self._real_stream_format,
|
||||
'StreamFormat': self._stream_format,
|
||||
'StreamQuality': stream_quality,
|
||||
'Recorder': f'{__prog__} v{__version__} {__github__}',
|
||||
})
|
@ -1,22 +1,20 @@
|
||||
import re
|
||||
import os
|
||||
import io
|
||||
import errno
|
||||
import shlex
|
||||
import io
|
||||
import logging
|
||||
from threading import Thread, Condition
|
||||
from subprocess import Popen, PIPE, CalledProcessError
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from subprocess import PIPE, CalledProcessError, Popen
|
||||
from threading import Condition, Thread
|
||||
from typing import Optional, cast
|
||||
|
||||
|
||||
from ..utils.mixins import StoppableMixin, SupportDebugMixin
|
||||
from ..utils.io import wait_for
|
||||
|
||||
from ..utils.mixins import StoppableMixin, SupportDebugMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
__all__ = 'StreamRemuxer',
|
||||
__all__ = ('StreamRemuxer',)
|
||||
|
||||
|
||||
class FFmpegError(Exception):
|
||||
@ -28,10 +26,10 @@ class StreamRemuxer(StoppableMixin, SupportDebugMixin):
|
||||
r'\b(error|failed|missing|invalid|corrupt)\b', re.IGNORECASE
|
||||
)
|
||||
|
||||
def __init__(self, room_id: int, bufsize: int = 1024 * 1024) -> None:
|
||||
def __init__(self, room_id: int, remove_filler_data: bool = False) -> None:
|
||||
super().__init__()
|
||||
self._room_id = room_id
|
||||
self._bufsize = bufsize
|
||||
self._remove_filler_data = remove_filler_data
|
||||
self._exception: Optional[Exception] = None
|
||||
self._ready = Condition()
|
||||
self._env = None
|
||||
@ -83,9 +81,7 @@ class StreamRemuxer(StoppableMixin, SupportDebugMixin):
|
||||
def _do_start(self) -> None:
|
||||
logger.debug('Starting stream remuxer...')
|
||||
self._thread = Thread(
|
||||
target=self._run,
|
||||
name=f'StreamRemuxer::{self._room_id}',
|
||||
daemon=True,
|
||||
target=self._run, name=f'StreamRemuxer::{self._room_id}', daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
@ -124,21 +120,21 @@ class StreamRemuxer(StoppableMixin, SupportDebugMixin):
|
||||
logger.debug('Stopped stream remuxer')
|
||||
|
||||
def _run_subprocess(self) -> None:
|
||||
cmd = 'ffmpeg -xerror -i pipe:0 -c copy -copyts -f flv pipe:1'
|
||||
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,
|
||||
bufsize=self._bufsize, env=self._env,
|
||||
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'
|
||||
self._subprocess.stderr, encoding='utf-8', errors='backslashreplace'
|
||||
) as stderr:
|
||||
while not self._stopped:
|
||||
line = wait_for(stderr.readline, timeout=10)
|
||||
|
1
src/blrec/data/webapp/474.c2cc33b068a782fc.js
Normal file
1
src/blrec/data/webapp/474.c2cc33b068a782fc.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
src/blrec/data/webapp/66.31f5b9ae46ae9005.js
Normal file
1
src/blrec/data/webapp/66.31f5b9ae46ae9005.js
Normal file
File diff suppressed because one or more lines are too long
1
src/blrec/data/webapp/869.42b1fd9a88732b97.js
Normal file
1
src/blrec/data/webapp/869.42b1fd9a88732b97.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -10,6 +10,6 @@
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
<noscript>Please enable JavaScript to continue using this application.</noscript>
|
||||
<script src="runtime.5296fd12ffdfadbe.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.b9234f0840c7101a.js" type="module"></script>
|
||||
<script src="runtime.6dfcba40e2a24845.js" type="module"></script><script src="polyfills.4b08448aee19bb22.js" type="module"></script><script src="main.b9234f0840c7101a.js" type="module"></script>
|
||||
|
||||
</body></html>
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"configVersion": 1,
|
||||
"timestamp": 1651566774775,
|
||||
"timestamp": 1651812628293,
|
||||
"index": "/index.html",
|
||||
"assetGroups": [
|
||||
{
|
||||
@ -14,15 +14,15 @@
|
||||
"/103.5b5d2a6e5a8a7479.js",
|
||||
"/146.92e3b29c4c754544.js",
|
||||
"/45.c90c3cea2bf1a66e.js",
|
||||
"/474.88f730916af2dc81.js",
|
||||
"/66.17103bf51c59b5c8.js",
|
||||
"/869.ac675e78fa0ea7cf.js",
|
||||
"/474.c2cc33b068a782fc.js",
|
||||
"/66.31f5b9ae46ae9005.js",
|
||||
"/869.42b1fd9a88732b97.js",
|
||||
"/common.858f777e9296e6f2.js",
|
||||
"/index.html",
|
||||
"/main.b9234f0840c7101a.js",
|
||||
"/manifest.webmanifest",
|
||||
"/polyfills.4b08448aee19bb22.js",
|
||||
"/runtime.5296fd12ffdfadbe.js",
|
||||
"/runtime.6dfcba40e2a24845.js",
|
||||
"/styles.1f581691b230dc4d.css"
|
||||
],
|
||||
"patterns": []
|
||||
@ -1637,9 +1637,9 @@
|
||||
"/103.5b5d2a6e5a8a7479.js": "cc0240f217015b6d4ddcc14f31fcc42e1c1c282a",
|
||||
"/146.92e3b29c4c754544.js": "3824de681dd1f982ea69a065cdf54d7a1e781f4d",
|
||||
"/45.c90c3cea2bf1a66e.js": "e5bfb8cf3803593e6b8ea14c90b3d3cb6a066764",
|
||||
"/474.88f730916af2dc81.js": "e7cb3e7bd68c162633d94c8c848dea2daeac8bc3",
|
||||
"/66.17103bf51c59b5c8.js": "67c9bb3ac7e7c7c25ebe1db69fc87890a2fdc184",
|
||||
"/869.ac675e78fa0ea7cf.js": "f45052016cb5201d5784b3f261e719d96bd1b153",
|
||||
"/474.c2cc33b068a782fc.js": "cb59c0b560cdceeccf9c17df5e9be76bfa942775",
|
||||
"/66.31f5b9ae46ae9005.js": "cc22d2582d8e4c2a83e089d5a1ec32619e439ccd",
|
||||
"/869.42b1fd9a88732b97.js": "ca5c951f04d02218b3fe7dc5c022dad22bf36eca",
|
||||
"/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": "4957097d609200632fe355aef8f7603a3bb1addc",
|
||||
"/index.html": "02b6c1c31185bec91e297c4f224b2d193f9c981c",
|
||||
"/main.b9234f0840c7101a.js": "c8c7b588c070b957a2659f62d6a77de284aa2233",
|
||||
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
|
||||
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
|
||||
"/runtime.5296fd12ffdfadbe.js": "5b84a91028ab9e0daaaf89d70f2d12c48d5e358e",
|
||||
"/runtime.6dfcba40e2a24845.js": "9dc2eec70103e3ee2249dde68eeb09910a7ae02d",
|
||||
"/styles.1f581691b230dc4d.css": "6f5befbbad57c2b2e80aae855139744b8010d150"
|
||||
},
|
||||
"navigationUrls": [
|
||||
|
@ -1 +1 @@
|
||||
(()=>{"use strict";var e,v={},m={};function r(e){var i=m[e];if(void 0!==i)return i.exports;var t=m[e]={exports:{}};return v[e].call(t.exports,t,t.exports,r),t.exports}r.m=v,e=[],r.O=(i,t,o,f)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,o,f]=e[n],c=!0,l=0;l<t.length;l++)(!1&f||a>=f)&&Object.keys(r.O).every(b=>r.O[b](t[l]))?t.splice(l--,1):(c=!1,f<a&&(a=f));if(c){e.splice(n--,1);var d=o();void 0!==d&&(i=d)}}return i}f=f||0;for(var n=e.length;n>0&&e[n-1][2]>f;n--)e[n]=e[n-1];e[n]=[t,o,f]},r.n=e=>{var i=e&&e.__esModule?()=>e.default:()=>e;return r.d(i,{a:i}),i},r.d=(e,i)=>{for(var t in i)r.o(i,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:i[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((i,t)=>(r.f[t](e,i),i),[])),r.u=e=>(592===e?"common":e)+"."+{45:"c90c3cea2bf1a66e",66:"17103bf51c59b5c8",103:"5b5d2a6e5a8a7479",146:"92e3b29c4c754544",474:"88f730916af2dc81",592:"858f777e9296e6f2",869:"ac675e78fa0ea7cf"}[e]+".js",r.miniCssF=e=>{},r.o=(e,i)=>Object.prototype.hasOwnProperty.call(e,i),(()=>{var e={},i="blrec:";r.l=(t,o,f,n)=>{if(e[t])e[t].push(o);else{var a,c;if(void 0!==f)for(var l=document.getElementsByTagName("script"),d=0;d<l.length;d++){var u=l[d];if(u.getAttribute("src")==t||u.getAttribute("data-webpack")==i+f){a=u;break}}a||(c=!0,(a=document.createElement("script")).type="module",a.charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",i+f),a.src=r.tu(t)),e[t]=[o];var s=(g,b)=>{a.onerror=a.onload=null,clearTimeout(p);var _=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),_&&_.forEach(h=>h(b)),g)return g(b)},p=setTimeout(s.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=s.bind(null,a.onerror),a.onload=s.bind(null,a.onload),c&&document.head.appendChild(a)}}})(),r.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tu=i=>(void 0===e&&(e={createScriptURL:t=>t},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e.createScriptURL(i))})(),r.p="",(()=>{var e={666:0};r.f.j=(o,f)=>{var n=r.o(e,o)?e[o]:void 0;if(0!==n)if(n)f.push(n[2]);else if(666!=o){var a=new Promise((u,s)=>n=e[o]=[u,s]);f.push(n[2]=a);var c=r.p+r.u(o),l=new Error;r.l(c,u=>{if(r.o(e,o)&&(0!==(n=e[o])&&(e[o]=void 0),n)){var s=u&&("load"===u.type?"missing":u.type),p=u&&u.target&&u.target.src;l.message="Loading chunk "+o+" failed.\n("+s+": "+p+")",l.name="ChunkLoadError",l.type=s,l.request=p,n[1](l)}},"chunk-"+o,o)}else e[o]=0},r.O.j=o=>0===e[o];var i=(o,f)=>{var l,d,[n,a,c]=f,u=0;if(n.some(p=>0!==e[p])){for(l in a)r.o(a,l)&&(r.m[l]=a[l]);if(c)var s=c(r)}for(o&&o(f);u<n.length;u++)r.o(e,d=n[u])&&e[d]&&e[d][0](),e[n[u]]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(i.bind(null,0)),t.push=i.bind(null,t.push.bind(t))})()})();
|
||||
(()=>{"use strict";var e,v={},m={};function r(e){var i=m[e];if(void 0!==i)return i.exports;var t=m[e]={exports:{}};return v[e].call(t.exports,t,t.exports,r),t.exports}r.m=v,e=[],r.O=(i,t,o,f)=>{if(!t){var a=1/0;for(n=0;n<e.length;n++){for(var[t,o,f]=e[n],c=!0,d=0;d<t.length;d++)(!1&f||a>=f)&&Object.keys(r.O).every(b=>r.O[b](t[d]))?t.splice(d--,1):(c=!1,f<a&&(a=f));if(c){e.splice(n--,1);var u=o();void 0!==u&&(i=u)}}return i}f=f||0;for(var n=e.length;n>0&&e[n-1][2]>f;n--)e[n]=e[n-1];e[n]=[t,o,f]},r.n=e=>{var i=e&&e.__esModule?()=>e.default:()=>e;return r.d(i,{a:i}),i},r.d=(e,i)=>{for(var t in i)r.o(i,t)&&!r.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,get:i[t]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((i,t)=>(r.f[t](e,i),i),[])),r.u=e=>(592===e?"common":e)+"."+{45:"c90c3cea2bf1a66e",66:"31f5b9ae46ae9005",103:"5b5d2a6e5a8a7479",146:"92e3b29c4c754544",474:"c2cc33b068a782fc",592:"858f777e9296e6f2",869:"42b1fd9a88732b97"}[e]+".js",r.miniCssF=e=>{},r.o=(e,i)=>Object.prototype.hasOwnProperty.call(e,i),(()=>{var e={},i="blrec:";r.l=(t,o,f,n)=>{if(e[t])e[t].push(o);else{var a,c;if(void 0!==f)for(var d=document.getElementsByTagName("script"),u=0;u<d.length;u++){var l=d[u];if(l.getAttribute("src")==t||l.getAttribute("data-webpack")==i+f){a=l;break}}a||(c=!0,(a=document.createElement("script")).type="module",a.charset="utf-8",a.timeout=120,r.nc&&a.setAttribute("nonce",r.nc),a.setAttribute("data-webpack",i+f),a.src=r.tu(t)),e[t]=[o];var s=(g,b)=>{a.onerror=a.onload=null,clearTimeout(p);var _=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),_&&_.forEach(h=>h(b)),g)return g(b)},p=setTimeout(s.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=s.bind(null,a.onerror),a.onload=s.bind(null,a.onload),c&&document.head.appendChild(a)}}})(),r.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var e;r.tu=i=>(void 0===e&&(e={createScriptURL:t=>t},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(e=trustedTypes.createPolicy("angular#bundler",e))),e.createScriptURL(i))})(),r.p="",(()=>{var e={666:0};r.f.j=(o,f)=>{var n=r.o(e,o)?e[o]:void 0;if(0!==n)if(n)f.push(n[2]);else if(666!=o){var a=new Promise((l,s)=>n=e[o]=[l,s]);f.push(n[2]=a);var c=r.p+r.u(o),d=new Error;r.l(c,l=>{if(r.o(e,o)&&(0!==(n=e[o])&&(e[o]=void 0),n)){var s=l&&("load"===l.type?"missing":l.type),p=l&&l.target&&l.target.src;d.message="Loading chunk "+o+" failed.\n("+s+": "+p+")",d.name="ChunkLoadError",d.type=s,d.request=p,n[1](d)}},"chunk-"+o,o)}else e[o]=0},r.O.j=o=>0===e[o];var i=(o,f)=>{var d,u,[n,a,c]=f,l=0;if(n.some(p=>0!==e[p])){for(d in a)r.o(a,d)&&(r.m[d]=a[d]);if(c)var s=c(r)}for(o&&o(f);l<n.length;l++)r.o(e,u=n[l])&&e[u]&&e[u][0](),e[n[l]]=0;return r.O(s)},t=self.webpackChunkblrec=self.webpackChunkblrec||[];t.forEach(i.bind(null,0)),t.push=i.bind(null,t.push.bind(t))})()})();
|
@ -41,6 +41,8 @@ def read_tags_in_duration(
|
||||
yield tag
|
||||
if tag.timestamp > 0:
|
||||
break
|
||||
else:
|
||||
raise EOFError('no tags')
|
||||
|
||||
start = tag.timestamp
|
||||
end = start + duration
|
||||
|
@ -381,11 +381,26 @@ class StreamProcessor:
|
||||
bytes_io = io.BytesIO()
|
||||
writer = FlvWriter(bytes_io)
|
||||
writer.write_header(flv_header)
|
||||
writer.write_tag(self._parameters_checker.last_metadata_tag)
|
||||
writer.write_tag(self._parameters_checker.last_video_header_tag)
|
||||
writer.write_tag(
|
||||
self._correct_ts(
|
||||
self._parameters_checker.last_metadata_tag,
|
||||
-self._parameters_checker.last_metadata_tag.timestamp,
|
||||
)
|
||||
)
|
||||
writer.write_tag(
|
||||
self._correct_ts(
|
||||
self._parameters_checker.last_video_header_tag,
|
||||
-self._parameters_checker.last_video_header_tag.timestamp,
|
||||
)
|
||||
)
|
||||
if self._parameters_checker.last_audio_header_tag is not None:
|
||||
writer.write_tag(self._parameters_checker.last_audio_header_tag)
|
||||
writer.write_tag(first_data_tag)
|
||||
writer.write_tag(
|
||||
self._correct_ts(
|
||||
self._parameters_checker.last_audio_header_tag,
|
||||
-self._parameters_checker.last_audio_header_tag.timestamp,
|
||||
)
|
||||
)
|
||||
writer.write_tag(self._correct_ts(first_data_tag, -first_data_tag.timestamp))
|
||||
|
||||
def on_next(profile: StreamProfile) -> None:
|
||||
self._stream_profile_updates.on_next(profile)
|
||||
|
@ -5,7 +5,7 @@ import ssl
|
||||
from abc import ABC, abstractmethod
|
||||
from email.message import EmailMessage
|
||||
from http.client import HTTPException
|
||||
from typing import Final, Literal, TypedDict, cast
|
||||
from typing import Final, Literal, TypedDict, Dict, Any, cast
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import aiohttp
|
||||
@ -192,7 +192,7 @@ class Pushplus(MessagingProvider):
|
||||
|
||||
class TelegramResponse(TypedDict):
|
||||
ok: bool
|
||||
result: dict
|
||||
result: Dict[str, Any]
|
||||
|
||||
|
||||
class Telegram(MessagingProvider):
|
||||
|
@ -245,6 +245,7 @@ class Postprocessor(
|
||||
out_path,
|
||||
metadata_path,
|
||||
report_progress=True,
|
||||
remove_filler_data=True,
|
||||
).subscribe(
|
||||
on_next,
|
||||
lambda e: future.set_exception(e),
|
||||
|
@ -61,12 +61,20 @@ class VideoRemuxer:
|
||||
in_path: str,
|
||||
out_path: str,
|
||||
metadata_path: Optional[str] = None,
|
||||
*,
|
||||
remove_filler_data: bool = False,
|
||||
) -> RemuxResult:
|
||||
cmd = f'ffmpeg -i "{in_path}"'
|
||||
if metadata_path is not None:
|
||||
cmd = f'ffmpeg -i "{in_path}" -i "{metadata_path}" ' \
|
||||
f'-map_metadata 1 -codec copy "{out_path}" -y'
|
||||
else:
|
||||
cmd = f'ffmpeg -i "{in_path}" -codec copy "{out_path}" -y'
|
||||
cmd += f' -i "{metadata_path}" -map_metadata 1'
|
||||
cmd += ' -codec copy'
|
||||
if remove_filler_data:
|
||||
# https://forum.doom9.org/showthread.php?t=152051
|
||||
# ISO_IEC_14496-10_2020(E)
|
||||
# Table 7-1 – NAL unit type codes, syntax element categories, and NAL unit type classes # noqa
|
||||
# 7.4.2.7 Filler data RBSP semantics
|
||||
cmd += ' -bsf:v filter_units=remove_types=12'
|
||||
cmd += f' "{out_path}" -y'
|
||||
|
||||
args = shlex.split(cmd)
|
||||
out_lines: List[str] = []
|
||||
@ -140,6 +148,7 @@ def remux_video(
|
||||
metadata_path: Optional[str] = None,
|
||||
*,
|
||||
report_progress: bool = False,
|
||||
remove_filler_data: bool = False,
|
||||
) -> Observable:
|
||||
def subscribe(
|
||||
observer: Observer[Union[RemuxProgress, RemuxResult]],
|
||||
@ -167,7 +176,12 @@ def remux_video(
|
||||
)
|
||||
|
||||
try:
|
||||
result = remuxer.remux(in_path, out_path, metadata_path)
|
||||
result = remuxer.remux(
|
||||
in_path,
|
||||
out_path,
|
||||
metadata_path,
|
||||
remove_filler_data=remove_filler_data,
|
||||
)
|
||||
except Exception as e:
|
||||
observer.on_error(e)
|
||||
else:
|
||||
|
@ -20,6 +20,7 @@ from pydantic.networks import HttpUrl, EmailStr
|
||||
|
||||
from ..bili.typing import StreamFormat, QualityNumber
|
||||
from ..postprocess import DeleteStrategy
|
||||
from ..core.cover_downloader import CoverSaveStrategy
|
||||
from ..logging.typing import LOG_LEVEL
|
||||
from ..utils.string import camel_case
|
||||
|
||||
@ -144,12 +145,21 @@ class DanmakuSettings(DanmakuOptions):
|
||||
class RecorderOptions(BaseModel):
|
||||
stream_format: Optional[StreamFormat]
|
||||
quality_number: Optional[QualityNumber]
|
||||
fmp4_stream_timeout: Optional[int]
|
||||
read_timeout: Optional[int] # seconds
|
||||
disconnection_timeout: Optional[int] # seconds
|
||||
buffer_size: Annotated[ # bytes
|
||||
Optional[int], Field(ge=4096, le=1024 ** 2 * 512, multiple_of=2)
|
||||
]
|
||||
save_cover: Optional[bool]
|
||||
cover_save_strategy: Optional[CoverSaveStrategy]
|
||||
|
||||
@validator('fmp4_stream_timeout')
|
||||
def _validate_fmp4_stream_timeout(cls, v: Optional[int]) -> Optional[int]:
|
||||
if v is not None:
|
||||
allowed_values = frozenset((3, 5, 10, 30, 60, 180, 300, 600))
|
||||
cls._validate_with_collection(v, allowed_values)
|
||||
return v
|
||||
|
||||
@validator('read_timeout')
|
||||
def _validate_read_timeout(cls, value: Optional[int]) -> Optional[int]:
|
||||
@ -171,12 +181,14 @@ class RecorderOptions(BaseModel):
|
||||
class RecorderSettings(RecorderOptions):
|
||||
stream_format: StreamFormat = 'flv'
|
||||
quality_number: QualityNumber = 20000 # 4K, the highest quality.
|
||||
fmp4_stream_timeout: int = 10
|
||||
read_timeout: int = 3
|
||||
disconnection_timeout: int = 600
|
||||
buffer_size: Annotated[
|
||||
int, Field(ge=4096, le=1024 ** 2 * 512, multiple_of=2)
|
||||
] = 8192
|
||||
save_cover: bool = False
|
||||
cover_save_strategy: CoverSaveStrategy = CoverSaveStrategy.DEFAULT
|
||||
|
||||
|
||||
class PostprocessingOptions(BaseModel):
|
||||
@ -465,8 +477,8 @@ class Settings(BaseModel):
|
||||
version: str = '1.0'
|
||||
|
||||
tasks: Annotated[List[TaskSettings], Field(max_items=100)] = []
|
||||
output: OutputSettings = OutputSettings()
|
||||
logging: LoggingSettings = LoggingSettings()
|
||||
output: OutputSettings = OutputSettings() # type: ignore
|
||||
logging: LoggingSettings = LoggingSettings() # type: ignore
|
||||
header: HeaderSettings = HeaderSettings()
|
||||
danmaku: DanmakuSettings = DanmakuSettings()
|
||||
recorder: RecorderSettings = RecorderSettings()
|
||||
|
@ -6,6 +6,7 @@ import attr
|
||||
|
||||
from ..bili.models import RoomInfo, UserInfo
|
||||
from ..bili.typing import StreamFormat, QualityNumber
|
||||
from ..core.cover_downloader import CoverSaveStrategy
|
||||
from ..postprocess import DeleteStrategy, PostprocessorStatus
|
||||
from ..postprocess.typing import Progress
|
||||
|
||||
@ -32,8 +33,8 @@ class TaskStatus:
|
||||
rec_rate: float # Number of Bytes per second
|
||||
danmu_total: int # Number of Danmu in total
|
||||
danmu_rate: float # Number of Danmu per minutes
|
||||
real_stream_format: StreamFormat
|
||||
real_quality_number: QualityNumber
|
||||
real_stream_format: Optional[StreamFormat]
|
||||
real_quality_number: Optional[QualityNumber]
|
||||
recording_path: Optional[str] = None
|
||||
postprocessor_status: PostprocessorStatus = PostprocessorStatus.WAITING
|
||||
postprocessing_path: Optional[str] = None
|
||||
@ -60,10 +61,12 @@ class TaskParam:
|
||||
# RecorderSettings
|
||||
stream_format: StreamFormat
|
||||
quality_number: QualityNumber
|
||||
fmp4_stream_timeout: int
|
||||
read_timeout: int
|
||||
disconnection_timeout: Optional[int]
|
||||
buffer_size: int
|
||||
save_cover: bool
|
||||
cover_save_strategy: CoverSaveStrategy
|
||||
# PostprocessingOptions
|
||||
remux_to_mp4: bool
|
||||
inject_extra_metadata: bool
|
||||
|
@ -19,6 +19,7 @@ from ..bili.live_monitor import LiveMonitor
|
||||
from ..bili.typing import StreamFormat, QualityNumber
|
||||
from ..core import Recorder
|
||||
from ..core.stream_analyzer import StreamProfile
|
||||
from ..core.cover_downloader import CoverSaveStrategy
|
||||
from ..postprocess import Postprocessor, PostprocessorStatus, DeleteStrategy
|
||||
from ..postprocess.remuxer import RemuxProgress
|
||||
from ..flv.metadata_injector import InjectProgress
|
||||
@ -257,6 +258,14 @@ class RecordTask:
|
||||
def save_cover(self, value: bool) -> None:
|
||||
self._recorder.save_cover = value
|
||||
|
||||
@property
|
||||
def cover_save_strategy(self) -> CoverSaveStrategy:
|
||||
return self._recorder.cover_save_strategy
|
||||
|
||||
@cover_save_strategy.setter
|
||||
def cover_save_strategy(self, value: CoverSaveStrategy) -> None:
|
||||
self._recorder.cover_save_strategy = value
|
||||
|
||||
@property
|
||||
def save_raw_danmaku(self) -> bool:
|
||||
return self._recorder.save_raw_danmaku
|
||||
@ -282,11 +291,19 @@ class RecordTask:
|
||||
self._recorder.quality_number = value
|
||||
|
||||
@property
|
||||
def real_stream_format(self) -> StreamFormat:
|
||||
def fmp4_stream_timeout(self) -> int:
|
||||
return self._recorder.fmp4_stream_timeout
|
||||
|
||||
@fmp4_stream_timeout.setter
|
||||
def fmp4_stream_timeout(self, value: int) -> None:
|
||||
self._recorder.fmp4_stream_timeout = value
|
||||
|
||||
@property
|
||||
def real_stream_format(self) -> Optional[StreamFormat]:
|
||||
return self._recorder.real_stream_format
|
||||
|
||||
@property
|
||||
def real_quality_number(self) -> QualityNumber:
|
||||
def real_quality_number(self) -> Optional[QualityNumber]:
|
||||
return self._recorder.real_quality_number
|
||||
|
||||
@property
|
||||
|
@ -285,10 +285,12 @@ class RecordTaskManager:
|
||||
task = self._get_task(room_id)
|
||||
task.stream_format = settings.stream_format
|
||||
task.quality_number = settings.quality_number
|
||||
task.fmp4_stream_timeout = settings.fmp4_stream_timeout
|
||||
task.read_timeout = settings.read_timeout
|
||||
task.disconnection_timeout = settings.disconnection_timeout
|
||||
task.buffer_size = settings.buffer_size
|
||||
task.save_cover = settings.save_cover
|
||||
task.cover_save_strategy = settings.cover_save_strategy
|
||||
|
||||
def apply_task_postprocessing_settings(
|
||||
self, room_id: int, settings: PostprocessingSettings
|
||||
@ -322,9 +324,11 @@ class RecordTaskManager:
|
||||
record_guard_buy=task.record_guard_buy,
|
||||
record_super_chat=task.record_super_chat,
|
||||
save_cover=task.save_cover,
|
||||
cover_save_strategy=task.cover_save_strategy,
|
||||
save_raw_danmaku=task.save_raw_danmaku,
|
||||
stream_format=task.stream_format,
|
||||
quality_number=task.quality_number,
|
||||
fmp4_stream_timeout=task.fmp4_stream_timeout,
|
||||
read_timeout=task.read_timeout,
|
||||
disconnection_timeout=task.disconnection_timeout,
|
||||
buffer_size=task.buffer_size,
|
||||
|
@ -15,7 +15,7 @@
|
||||
<input
|
||||
id="server"
|
||||
type="url"
|
||||
placeholder="默认为官方服务器 https://api2.pushdeer.com"
|
||||
placeholder="默认为官方服务器:https://api2.pushdeer.com"
|
||||
nz-input
|
||||
formControlName="server"
|
||||
/>
|
||||
|
@ -8,15 +8,20 @@
|
||||
>
|
||||
<ng-template #streamFormatTip>
|
||||
<p>
|
||||
选择要录制的直播流格式<br />
|
||||
选择要录制的直播流格式
|
||||
<br />
|
||||
FLV 网络不稳定容易中断丢失数据 <br />
|
||||
HLS (ts) 基本不受本地网络影响 <br />
|
||||
HLS (fmp4) 只有少数直播间支持 <br />
|
||||
FLV: 网络不稳定容易中断丢失数据或录制到二压画质
|
||||
<br />
|
||||
P.S.<br />
|
||||
非 FLV 格式需要 ffmpeg<br />
|
||||
HLS (fmp4) 不支持会自动切换到 HLS (ts)<br />
|
||||
HLS (fmp4): 基本不受网络波动影响,但只有部分直播间支持。
|
||||
<br />
|
||||
P.S.
|
||||
<br />
|
||||
录制 HLS 流需要 ffmpeg
|
||||
<br />
|
||||
在设定时间内没有 fmp4 流会自动切换录制 flv 流
|
||||
<br />
|
||||
WEB 端直播播放器是 Hls7Player 的直播间支持录制 fmp4 流, fMp4Player
|
||||
则不支持。
|
||||
</p>
|
||||
</ng-template>
|
||||
<nz-form-control
|
||||
@ -33,6 +38,39 @@
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
[nzTooltipTitle]="fmp4StreamTimeoutTip"
|
||||
>fmp4 流等待时间</nz-form-label
|
||||
>
|
||||
<ng-template #fmp4StreamTimeoutTip>
|
||||
<p>
|
||||
如果超过所设置的等待时间 fmp4 流还没有就切换为录制 flv 流
|
||||
<br />
|
||||
fmp4 流在刚推流是没有的,要过一会才有。
|
||||
<br />
|
||||
fmp4 流出现的时间和直播延迟有关,一般都在 10 秒内,但也有延迟比较大超过
|
||||
1 分钟的。
|
||||
<br />
|
||||
推荐全局设置为 10 秒,个别延迟比较大的直播间单独设置。
|
||||
</p>
|
||||
</ng-template>
|
||||
<nz-form-control
|
||||
class="setting-control select"
|
||||
[nzWarningTip]="syncFailedWarningTip"
|
||||
[nzValidateStatus]="
|
||||
syncStatus.fmp4StreamTimeout ? fmp4StreamTimeoutControl : 'warning'
|
||||
"
|
||||
>
|
||||
<nz-select
|
||||
formControlName="fmp4StreamTimeout"
|
||||
[nzOptions]="fmp4StreamTimeoutOptions"
|
||||
>
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
@ -66,6 +104,41 @@
|
||||
<nz-switch formControlName="saveCover"></nz-switch>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
[nzTooltipTitle]="coverSaveStrategyTip"
|
||||
>封面保存策略</nz-form-label
|
||||
>
|
||||
<ng-template #coverSaveStrategyTip>
|
||||
<p>
|
||||
默认: 每个分割的录播文件对应保存一个封面文件,不管封面是否相同。<br />
|
||||
去重: 相同的封面只保存一次<br />
|
||||
P.S.
|
||||
<br />
|
||||
判断是否相同是依据封面数据的 sha1,只在单次录制内有效。
|
||||
</p>
|
||||
</ng-template>
|
||||
<nz-form-control
|
||||
class="setting-control radio"
|
||||
[nzWarningTip]="syncFailedWarningTip"
|
||||
[nzValidateStatus]="
|
||||
syncStatus.coverSaveStrategy ? coverSaveStrategyControl : 'warning'
|
||||
"
|
||||
>
|
||||
<nz-radio-group
|
||||
formControlName="coverSaveStrategy"
|
||||
[nzDisabled]="!saveCoverControl.value"
|
||||
>
|
||||
<ng-container *ngFor="let strategy of coverSaveStrategies">
|
||||
<label nz-radio-button [nzValue]="strategy.value">{{
|
||||
strategy.label
|
||||
}}</label>
|
||||
</ng-container>
|
||||
</nz-radio-group>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
@ -84,7 +157,7 @@
|
||||
: 'warning'
|
||||
"
|
||||
>
|
||||
<nz-select formControlName="readTimeout" [nzOptions]="timeoutOptions">
|
||||
<nz-select formControlName="readTimeout" [nzOptions]="readTimeoutOptions">
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
</nz-form-item>
|
||||
|
@ -20,6 +20,7 @@ import {
|
||||
TIMEOUT_OPTIONS,
|
||||
DISCONNECTION_TIMEOUT_OPTIONS,
|
||||
SYNC_FAILED_WARNING_TIP,
|
||||
COVER_SAVE_STRATEGIES,
|
||||
} from '../shared/constants/form';
|
||||
import { RecorderSettings } from '../shared/setting.model';
|
||||
import {
|
||||
@ -43,10 +44,13 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
|
||||
readonly streamFormatOptions = cloneDeep(STREAM_FORMAT_OPTIONS) as Mutable<
|
||||
typeof STREAM_FORMAT_OPTIONS
|
||||
>;
|
||||
readonly fmp4StreamTimeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
|
||||
typeof TIMEOUT_OPTIONS
|
||||
>;
|
||||
readonly qualityOptions = cloneDeep(QUALITY_OPTIONS) as Mutable<
|
||||
typeof QUALITY_OPTIONS
|
||||
>;
|
||||
readonly timeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
|
||||
readonly readTimeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
|
||||
typeof TIMEOUT_OPTIONS
|
||||
>;
|
||||
readonly disconnectionTimeoutOptions = cloneDeep(
|
||||
@ -55,6 +59,9 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
|
||||
readonly bufferOptions = cloneDeep(BUFFER_OPTIONS) as Mutable<
|
||||
typeof BUFFER_OPTIONS
|
||||
>;
|
||||
readonly coverSaveStrategies = cloneDeep(COVER_SAVE_STRATEGIES) as Mutable<
|
||||
typeof COVER_SAVE_STRATEGIES
|
||||
>;
|
||||
|
||||
constructor(
|
||||
formBuilder: FormBuilder,
|
||||
@ -64,10 +71,12 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
|
||||
this.settingsForm = formBuilder.group({
|
||||
streamFormat: [''],
|
||||
qualityNumber: [''],
|
||||
fmp4StreamTimeout: [''],
|
||||
readTimeout: [''],
|
||||
disconnectionTimeout: [''],
|
||||
bufferSize: [''],
|
||||
saveCover: [''],
|
||||
coverSaveStrategy: [''],
|
||||
});
|
||||
}
|
||||
|
||||
@ -79,6 +88,10 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
|
||||
return this.settingsForm.get('qualityNumber') as FormControl;
|
||||
}
|
||||
|
||||
get fmp4StreamTimeoutControl() {
|
||||
return this.settingsForm.get('fmp4StreamTimeout') as FormControl;
|
||||
}
|
||||
|
||||
get readTimeoutControl() {
|
||||
return this.settingsForm.get('readTimeout') as FormControl;
|
||||
}
|
||||
@ -95,6 +108,10 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
|
||||
return this.settingsForm.get('saveCover') as FormControl;
|
||||
}
|
||||
|
||||
get coverSaveStrategyControl() {
|
||||
return this.settingsForm.get('coverSaveStrategy') as FormControl;
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.syncStatus = mapValues(this.settings, () => true);
|
||||
this.settingsForm.setValue(this.settings);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { DeleteStrategy } from '../setting.model';
|
||||
import { CoverSaveStrategy, DeleteStrategy } from '../setting.model';
|
||||
|
||||
import range from 'lodash-es/range';
|
||||
|
||||
@ -52,9 +52,14 @@ export const DELETE_STRATEGIES = [
|
||||
{ label: '从不', value: DeleteStrategy.NEVER },
|
||||
] as const;
|
||||
|
||||
export const COVER_SAVE_STRATEGIES = [
|
||||
{ label: '默认', value: CoverSaveStrategy.DEFAULT },
|
||||
{ label: '去重', value: CoverSaveStrategy.DEDUP },
|
||||
] as const;
|
||||
|
||||
export const STREAM_FORMAT_OPTIONS = [
|
||||
{ label: 'FLV', value: 'flv' },
|
||||
{ label: 'HLS (ts)', value: 'ts' },
|
||||
// { label: 'HLS (ts)', value: 'ts' },
|
||||
{ label: 'HLS (fmp4)', value: 'fmp4' },
|
||||
] as const;
|
||||
|
||||
|
@ -29,13 +29,20 @@ export type QualityNumber =
|
||||
| 150 // 高清
|
||||
| 80; // 流畅
|
||||
|
||||
export enum CoverSaveStrategy {
|
||||
DEFAULT = 'default',
|
||||
DEDUP = 'dedup',
|
||||
}
|
||||
|
||||
export interface RecorderSettings {
|
||||
streamFormat: StreamFormat;
|
||||
qualityNumber: QualityNumber;
|
||||
fmp4StreamTimeout: number;
|
||||
readTimeout: number;
|
||||
disconnectionTimeout: number;
|
||||
bufferSize: number;
|
||||
saveCover: boolean;
|
||||
coverSaveStrategy: CoverSaveStrategy;
|
||||
}
|
||||
|
||||
export type RecorderOptions = Nullable<RecorderSettings>;
|
||||
|
@ -62,11 +62,19 @@
|
||||
<span class="label">格式画质</span
|
||||
><span class="value">
|
||||
<span>
|
||||
{{ data.task_status.real_stream_format }}
|
||||
{{
|
||||
data.task_status.real_stream_format
|
||||
? data.task_status.real_stream_format
|
||||
: "N/A"
|
||||
}}
|
||||
</span>
|
||||
<span>
|
||||
{{ data.task_status.real_quality_number | quality }}
|
||||
({{ data.task_status.real_quality_number
|
||||
{{
|
||||
data.task_status.real_quality_number
|
||||
? (data.task_status.real_quality_number | quality)
|
||||
: "N/A"
|
||||
}}
|
||||
({{ data.task_status.real_quality_number ?? "N/A"
|
||||
}}<ng-container *ngIf="isBlurayStreamQuality()">, bluray</ng-container
|
||||
>)
|
||||
</span>
|
||||
|
@ -32,10 +32,12 @@ export class TaskManagerService {
|
||||
return this.taskService.updateTaskInfo(roomId).pipe(
|
||||
tap(
|
||||
() => {
|
||||
this.message.success('成功刷新任务的数据');
|
||||
this.message.success(`[${roomId}] 成功刷新任务的数据`);
|
||||
},
|
||||
(error: HttpErrorResponse) => {
|
||||
this.message.error(`刷新任务的数据出错: ${error.message}`);
|
||||
this.message.error(
|
||||
`[${roomId}] 刷新任务的数据出错: ${error.message}`
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
@ -101,10 +103,10 @@ export class TaskManagerService {
|
||||
return this.taskService.removeTask(roomId).pipe(
|
||||
tap(
|
||||
() => {
|
||||
this.message.success('任务已删除');
|
||||
this.message.success(`[${roomId}] 任务已删除`);
|
||||
},
|
||||
(error: HttpErrorResponse) => {
|
||||
this.message.error(`删除任务出错: ${error.message}`);
|
||||
this.message.error(`[${roomId}] 删除任务出错: ${error.message}`);
|
||||
}
|
||||
)
|
||||
);
|
||||
@ -129,18 +131,18 @@ export class TaskManagerService {
|
||||
}
|
||||
|
||||
startTask(roomId: number): Observable<ResponseMessage> {
|
||||
const messageId = this.message.loading('正在运行任务...', {
|
||||
const messageId = this.message.loading(`[${roomId}] 正在运行任务...`, {
|
||||
nzDuration: 0,
|
||||
}).messageId;
|
||||
return this.taskService.startTask(roomId).pipe(
|
||||
tap(
|
||||
() => {
|
||||
this.message.remove(messageId);
|
||||
this.message.success('成功运行任务');
|
||||
this.message.success(`[${roomId}] 成功运行任务`);
|
||||
},
|
||||
(error: HttpErrorResponse) => {
|
||||
this.message.remove(messageId);
|
||||
this.message.error(`运行任务出错: ${error.message}`);
|
||||
this.message.error(`[${roomId}] 运行任务出错: ${error.message}`);
|
||||
}
|
||||
)
|
||||
);
|
||||
@ -168,18 +170,18 @@ export class TaskManagerService {
|
||||
roomId: number,
|
||||
force: boolean = false
|
||||
): Observable<ResponseMessage> {
|
||||
const messageId = this.message.loading('正在停止任务...', {
|
||||
const messageId = this.message.loading(`[${roomId}] 正在停止任务...`, {
|
||||
nzDuration: 0,
|
||||
}).messageId;
|
||||
return this.taskService.stopTask(roomId, force).pipe(
|
||||
tap(
|
||||
() => {
|
||||
this.message.remove(messageId);
|
||||
this.message.success('成功停止任务');
|
||||
this.message.success(`[${roomId}] 成功停止任务`);
|
||||
},
|
||||
(error: HttpErrorResponse) => {
|
||||
this.message.remove(messageId);
|
||||
this.message.error(`停止任务出错: ${error.message}`);
|
||||
this.message.error(`[${roomId}] 停止任务出错: ${error.message}`);
|
||||
}
|
||||
)
|
||||
);
|
||||
@ -204,18 +206,18 @@ export class TaskManagerService {
|
||||
}
|
||||
|
||||
enableRecorder(roomId: number): Observable<ResponseMessage> {
|
||||
const messageId = this.message.loading('正在开启录制...', {
|
||||
const messageId = this.message.loading(`[${roomId}] 正在开启录制...`, {
|
||||
nzDuration: 0,
|
||||
}).messageId;
|
||||
return this.taskService.enableTaskRecorder(roomId).pipe(
|
||||
tap(
|
||||
() => {
|
||||
this.message.remove(messageId);
|
||||
this.message.success('成功开启录制');
|
||||
this.message.success(`[${roomId}] 成功开启录制`);
|
||||
},
|
||||
(error: HttpErrorResponse) => {
|
||||
this.message.remove(messageId);
|
||||
this.message.error(`开启录制出错: ${error.message}`);
|
||||
this.message.error(`[${roomId}] 开启录制出错: ${error.message}`);
|
||||
}
|
||||
)
|
||||
);
|
||||
@ -248,18 +250,18 @@ export class TaskManagerService {
|
||||
roomId: number,
|
||||
force: boolean = false
|
||||
): Observable<ResponseMessage> {
|
||||
const messageId = this.message.loading('正在关闭录制...', {
|
||||
const messageId = this.message.loading(`[${roomId}] 正在关闭录制...`, {
|
||||
nzDuration: 0,
|
||||
}).messageId;
|
||||
return this.taskService.disableTaskRecorder(roomId, force).pipe(
|
||||
tap(
|
||||
() => {
|
||||
this.message.remove(messageId);
|
||||
this.message.success('成功关闭录制');
|
||||
this.message.success(`[${roomId}] 成功关闭录制`);
|
||||
},
|
||||
(error: HttpErrorResponse) => {
|
||||
this.message.remove(messageId);
|
||||
this.message.error(`关闭录制出错: ${error.message}`);
|
||||
this.message.error(`[${roomId}] 关闭录制出错: ${error.message}`);
|
||||
}
|
||||
)
|
||||
);
|
||||
@ -287,13 +289,13 @@ export class TaskManagerService {
|
||||
return this.taskService.cutStream(roomId).pipe(
|
||||
tap(
|
||||
() => {
|
||||
this.message.success('文件切割已触发');
|
||||
this.message.success(`[${roomId}] 文件切割已触发`);
|
||||
},
|
||||
(error: HttpErrorResponse) => {
|
||||
if (error.status == 403) {
|
||||
this.message.warning('时长太短不能切割,请稍后再试。');
|
||||
this.message.warning(`[${roomId}] 时长太短不能切割,请稍后再试。`);
|
||||
} else {
|
||||
this.message.error(`切割文件出错: ${error.message}`);
|
||||
this.message.error(`[${roomId}] 切割文件出错: ${error.message}`);
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -91,8 +91,8 @@ export interface TaskStatus {
|
||||
readonly rec_rate: number;
|
||||
readonly danmu_total: number;
|
||||
readonly danmu_rate: number;
|
||||
readonly real_stream_format: StreamFormat;
|
||||
readonly real_quality_number: QualityNumber;
|
||||
readonly real_stream_format: StreamFormat | null;
|
||||
readonly real_quality_number: QualityNumber | null;
|
||||
readonly recording_path: string | null;
|
||||
readonly postprocessor_status: PostprocessorStatus;
|
||||
readonly postprocessing_path: string | null;
|
||||
|
@ -47,7 +47,11 @@
|
||||
nzTooltipTitle="录制画质"
|
||||
nzTooltipPlacement="leftTop"
|
||||
>
|
||||
{{ status.real_quality_number | quality }}
|
||||
{{
|
||||
status.real_quality_number
|
||||
? (status.real_quality_number | quality)
|
||||
: ""
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
@ -29,11 +29,13 @@
|
||||
[nzValueTemplate]="recordingQuality"
|
||||
></nz-statistic>
|
||||
<ng-template #recordingQuality>{{
|
||||
(taskStatus.real_quality_number | quality)! +
|
||||
taskStatus.real_quality_number
|
||||
? (taskStatus.real_quality_number | quality) +
|
||||
" " +
|
||||
"(" +
|
||||
taskStatus.real_quality_number +
|
||||
")"
|
||||
: ""
|
||||
}}</ng-template>
|
||||
|
||||
<nz-statistic
|
||||
|
@ -119,15 +119,20 @@
|
||||
>
|
||||
<ng-template #streamFormatTip>
|
||||
<p>
|
||||
选择要录制的直播流格式<br />
|
||||
选择要录制的直播流格式
|
||||
<br />
|
||||
FLV 网络不稳定容易中断丢失数据 <br />
|
||||
HLS (ts) 基本不受本地网络影响 <br />
|
||||
HLS (fmp4) 只有少数直播间支持 <br />
|
||||
FLV: 网络不稳定容易中断丢失数据或录制到二压画质
|
||||
<br />
|
||||
P.S.<br />
|
||||
非 FLV 格式需要 ffmpeg<br />
|
||||
HLS (fmp4) 不支持会自动切换到 HLS (ts)<br />
|
||||
HLS (fmp4): 基本不受网络波动影响,但只有部分直播间支持。
|
||||
<br />
|
||||
P.S.
|
||||
<br />
|
||||
录制 HLS 流需要 ffmpeg
|
||||
<br />
|
||||
在设定时间内没有 fmp4 流会自动切换录制 flv 流
|
||||
<br />
|
||||
WEB 端直播播放器是 Hls7Player 的直播间支持录制 fmp4 流, fMp4Player
|
||||
则不支持。
|
||||
</p>
|
||||
</ng-template>
|
||||
<nz-form-control class="setting-control select">
|
||||
@ -150,6 +155,46 @@
|
||||
>覆盖全局设置</label
|
||||
>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
[nzTooltipTitle]="fmp4StreamTimeoutTip"
|
||||
>fmp4 流等待时间</nz-form-label
|
||||
>
|
||||
<ng-template #fmp4StreamTimeoutTip>
|
||||
<p>
|
||||
如果超过所设置的等待时间 fmp4 流还没有就切换为录制 flv 流
|
||||
<br />
|
||||
fmp4 流在刚推流是没有的,要过一会才有。
|
||||
<br />
|
||||
fmp4 流出现的时间和直播延迟有关,一般都在 10
|
||||
秒内,但也有延迟比较大超过 1 分钟的。
|
||||
<br />
|
||||
推荐全局设置为 10 秒,个别延迟比较大的直播间单独设置。
|
||||
</p>
|
||||
</ng-template>
|
||||
<nz-form-control class="setting-control select">
|
||||
<nz-select
|
||||
#fmp4StreamTimeout="ngModel"
|
||||
name="fmp4StreamTimeout"
|
||||
[(ngModel)]="model.recorder.fmp4StreamTimeout"
|
||||
[disabled]="options.recorder.fmp4StreamTimeout === null"
|
||||
[nzOptions]="fmp4StreamTimeoutOptions"
|
||||
>
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
<label
|
||||
nz-checkbox
|
||||
[nzChecked]="options.recorder.fmp4StreamTimeout !== null"
|
||||
(nzCheckedChange)="
|
||||
options.recorder.fmp4StreamTimeout = $event
|
||||
? globalSettings.recorder.fmp4StreamTimeout
|
||||
: null
|
||||
"
|
||||
>覆盖全局设置</label
|
||||
>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
@ -202,6 +247,45 @@
|
||||
>覆盖全局设置</label
|
||||
>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
nzNoColon
|
||||
[nzTooltipTitle]="coverSaveStrategyTip"
|
||||
>封面保存策略</nz-form-label
|
||||
>
|
||||
<ng-template #coverSaveStrategyTip>
|
||||
<p>
|
||||
默认:
|
||||
每个分割的录播文件对应保存一个封面文件,不管封面是否相同。<br />
|
||||
去重: 相同的封面只保存一次<br />
|
||||
P.S.
|
||||
<br />
|
||||
判断是否相同是依据封面数据的 sha1,只在单次录制内有效。
|
||||
</p>
|
||||
</ng-template>
|
||||
<nz-form-control class="setting-control select">
|
||||
<nz-select
|
||||
name="coverSaveStrategy"
|
||||
[(ngModel)]="model.recorder.coverSaveStrategy"
|
||||
[disabled]="
|
||||
options.recorder.coverSaveStrategy === null ||
|
||||
!options.recorder.saveCover
|
||||
"
|
||||
[nzOptions]="coverSaveStrategies"
|
||||
></nz-select>
|
||||
</nz-form-control>
|
||||
<label
|
||||
nz-checkbox
|
||||
[nzChecked]="options.recorder.coverSaveStrategy !== null"
|
||||
(nzCheckedChange)="
|
||||
options.recorder.coverSaveStrategy = $event
|
||||
? globalSettings.recorder.coverSaveStrategy
|
||||
: null
|
||||
"
|
||||
>覆盖全局设置</label
|
||||
>
|
||||
</nz-form-item>
|
||||
<nz-form-item class="setting-item">
|
||||
<nz-form-label
|
||||
class="setting-label"
|
||||
@ -219,7 +303,7 @@
|
||||
name="readTimeout"
|
||||
[(ngModel)]="model.recorder.readTimeout"
|
||||
[disabled]="options.recorder.readTimeout === null"
|
||||
[nzOptions]="timeoutOptions"
|
||||
[nzOptions]="readTimeoutOptions"
|
||||
>
|
||||
</nz-select>
|
||||
</nz-form-control>
|
||||
|
@ -30,6 +30,7 @@ import {
|
||||
BUFFER_OPTIONS,
|
||||
DELETE_STRATEGIES,
|
||||
SPLIT_FILE_TIP,
|
||||
COVER_SAVE_STRATEGIES,
|
||||
} from '../../settings/shared/constants/form';
|
||||
|
||||
type OptionsModel = NonNullable<TaskOptions>;
|
||||
@ -67,10 +68,13 @@ export class TaskSettingsDialogComponent implements OnChanges {
|
||||
readonly streamFormatOptions = cloneDeep(STREAM_FORMAT_OPTIONS) as Mutable<
|
||||
typeof STREAM_FORMAT_OPTIONS
|
||||
>;
|
||||
readonly fmp4StreamTimeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
|
||||
typeof TIMEOUT_OPTIONS
|
||||
>;
|
||||
readonly qualityOptions = cloneDeep(QUALITY_OPTIONS) as Mutable<
|
||||
typeof QUALITY_OPTIONS
|
||||
>;
|
||||
readonly timeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
|
||||
readonly readTimeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
|
||||
typeof TIMEOUT_OPTIONS
|
||||
>;
|
||||
readonly disconnectionTimeoutOptions = cloneDeep(
|
||||
@ -82,6 +86,9 @@ export class TaskSettingsDialogComponent implements OnChanges {
|
||||
readonly deleteStrategies = cloneDeep(DELETE_STRATEGIES) as Mutable<
|
||||
typeof DELETE_STRATEGIES
|
||||
>;
|
||||
readonly coverSaveStrategies = cloneDeep(COVER_SAVE_STRATEGIES) as Mutable<
|
||||
typeof COVER_SAVE_STRATEGIES
|
||||
>;
|
||||
|
||||
model!: OptionsModel;
|
||||
options!: TaskOptions;
|
||||
|
@ -3,5 +3,11 @@
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
]
|
||||
],
|
||||
"settings": {
|
||||
"python.linting.mypyPath": "mypy",
|
||||
"python.linting.flake8Path": "flake8",
|
||||
"python.formatting.blackPath": "black",
|
||||
"python.sortImports.path": "isort"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user