parent
d5d9ece36a
commit
5ebeb38b69
3
.flake8
3
.flake8
@ -1,5 +1,6 @@
|
|||||||
[flake8]
|
[flake8]
|
||||||
ignore = D203, W504
|
max-line-length = 88
|
||||||
|
ignore = D203, W504, W503
|
||||||
exclude =
|
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
|
## 1.6.2
|
||||||
|
|
||||||
- 忽略 Windows 注册表 JavaScript MIME 设置 (issue #12, 27)
|
- 忽略 Windows 注册表 JavaScript MIME 设置 (issue #12, 27)
|
||||||
|
@ -4,3 +4,13 @@ requires = [
|
|||||||
"wheel >= 0.37, < 0.38.0",
|
"wheel >= 0.37, < 0.38.0",
|
||||||
]
|
]
|
||||||
build-backend = "setuptools.build_meta"
|
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
|
python_requires = >= 3.8
|
||||||
install_requires =
|
install_requires =
|
||||||
typing-extensions >= 3.10.0.0
|
typing-extensions >= 3.10.0.0
|
||||||
|
ordered-set >= 4.1.0, < 5.0.0
|
||||||
fastapi >= 0.70.0, < 0.71.0
|
fastapi >= 0.70.0, < 0.71.0
|
||||||
email_validator >= 1.1.3, < 2.0.0
|
email_validator >= 1.1.3, < 2.0.0
|
||||||
click < 8.1.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
|
aiohttp >= 3.8.1, < 4.0.0
|
||||||
requests >= 2.24.0, < 3.0.0
|
requests >= 2.24.0, < 3.0.0
|
||||||
aiofiles >= 0.8.0, < 0.9.0
|
aiofiles >= 0.8.0, < 0.9.0
|
||||||
@ -61,7 +62,9 @@ install_requires =
|
|||||||
[options.extras_require]
|
[options.extras_require]
|
||||||
dev =
|
dev =
|
||||||
flake8 >= 4.0.1
|
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
|
setuptools >= 59.4.0
|
||||||
wheel >= 0.37
|
wheel >= 0.37
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
|
|
||||||
__prog__ = 'blrec'
|
__prog__ = 'blrec'
|
||||||
__version__ = '1.6.2'
|
__version__ = '1.7.0'
|
||||||
__github__ = 'https://github.com/acgnhiki/blrec'
|
__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 .. import __version__, __prog__, __github__
|
||||||
from .danmaku_receiver import DanmakuReceiver, DanmuMsg
|
from .danmaku_receiver import DanmakuReceiver, DanmuMsg
|
||||||
from .base_stream_recorder import (
|
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
||||||
BaseStreamRecorder, StreamRecorderEventListener
|
|
||||||
)
|
|
||||||
from .statistics import StatisticsCalculator
|
from .statistics import StatisticsCalculator
|
||||||
from ..bili.live import Live
|
from ..bili.live import Live
|
||||||
from ..exception import exception_callback, submit_exception
|
from ..exception import exception_callback, submit_exception
|
||||||
@ -51,7 +49,7 @@ class DanmakuDumper(
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
live: Live,
|
live: Live,
|
||||||
stream_recorder: BaseStreamRecorder,
|
stream_recorder: StreamRecorder,
|
||||||
danmaku_receiver: DanmakuReceiver,
|
danmaku_receiver: DanmakuReceiver,
|
||||||
*,
|
*,
|
||||||
danmu_uname: bool = False,
|
danmu_uname: bool = False,
|
||||||
@ -72,6 +70,7 @@ class DanmakuDumper(
|
|||||||
self.record_guard_buy = record_guard_buy
|
self.record_guard_buy = record_guard_buy
|
||||||
self.record_super_chat = record_super_chat
|
self.record_super_chat = record_super_chat
|
||||||
|
|
||||||
|
self._lock: asyncio.Lock = asyncio.Lock()
|
||||||
self._path: Optional[str] = None
|
self._path: Optional[str] = None
|
||||||
self._files: List[str] = []
|
self._files: List[str] = []
|
||||||
self._calculator = StatisticsCalculator(interval=60)
|
self._calculator = StatisticsCalculator(interval=60)
|
||||||
@ -92,14 +91,6 @@ class DanmakuDumper(
|
|||||||
def dumping_path(self) -> Optional[str]:
|
def dumping_path(self) -> Optional[str]:
|
||||||
return self._path
|
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:
|
def _do_enable(self) -> None:
|
||||||
self._stream_recorder.add_listener(self)
|
self._stream_recorder.add_listener(self)
|
||||||
logger.debug('Enabled danmaku dumper')
|
logger.debug('Enabled danmaku dumper')
|
||||||
@ -124,12 +115,14 @@ class DanmakuDumper(
|
|||||||
async def on_video_file_created(
|
async def on_video_file_created(
|
||||||
self, video_path: str, record_start_time: int
|
self, video_path: str, record_start_time: int
|
||||||
) -> None:
|
) -> None:
|
||||||
|
async with self._lock:
|
||||||
self._path = danmaku_path(video_path)
|
self._path = danmaku_path(video_path)
|
||||||
self._record_start_time = record_start_time
|
self._record_start_time = record_start_time
|
||||||
self._files.append(self._path)
|
self._files.append(self._path)
|
||||||
self._start_dumping()
|
self._start_dumping()
|
||||||
|
|
||||||
async def on_video_file_completed(self, video_path: str) -> None:
|
async def on_video_file_completed(self, video_path: str) -> None:
|
||||||
|
async with self._lock:
|
||||||
await self._stop_dumping()
|
await self._stop_dumping()
|
||||||
self._path = None
|
self._path = None
|
||||||
|
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
class FailedToFetchSegments(Exception):
|
|
||||||
pass
|
|
@ -1,51 +1,33 @@
|
|||||||
import io
|
import io
|
||||||
import errno
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import urllib3
|
|
||||||
import requests
|
import requests
|
||||||
|
import urllib3
|
||||||
|
from tenacity import TryAgain
|
||||||
from tqdm import tqdm
|
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.live import Live
|
||||||
from ..bili.typing import StreamFormat, QualityNumber
|
from ..bili.typing import QualityNumber
|
||||||
from ..flv.stream_processor import StreamProcessor
|
|
||||||
from ..utils.mixins import AsyncCooperationMixin, AsyncStoppableMixin
|
|
||||||
from ..flv.exceptions import FlvDataError, FlvStreamCorruptedError
|
from ..flv.exceptions import FlvDataError, FlvStreamCorruptedError
|
||||||
from ..bili.exceptions import (
|
from ..flv.stream_processor import StreamProcessor
|
||||||
LiveRoomHidden, LiveRoomLocked, LiveRoomEncrypted, NoStreamAvailable,
|
from .stream_analyzer import StreamProfile
|
||||||
)
|
from .stream_recorder_impl import StreamProxy, StreamRecorderImpl
|
||||||
|
|
||||||
|
__all__ = 'FLVStreamRecorderImpl',
|
||||||
__all__ = 'FLVStreamRecorder',
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class FLVStreamRecorder(
|
class FLVStreamRecorderImpl(StreamRecorderImpl):
|
||||||
BaseStreamRecorder,
|
|
||||||
AsyncCooperationMixin,
|
|
||||||
AsyncStoppableMixin,
|
|
||||||
):
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
live: Live,
|
live: Live,
|
||||||
out_dir: str,
|
out_dir: str,
|
||||||
path_template: str,
|
path_template: str,
|
||||||
*,
|
*,
|
||||||
stream_format: StreamFormat = 'flv',
|
|
||||||
quality_number: QualityNumber = 10000,
|
quality_number: QualityNumber = 10000,
|
||||||
buffer_size: Optional[int] = None,
|
buffer_size: Optional[int] = None,
|
||||||
read_timeout: Optional[int] = None,
|
read_timeout: Optional[int] = None,
|
||||||
@ -57,7 +39,7 @@ class FLVStreamRecorder(
|
|||||||
live=live,
|
live=live,
|
||||||
out_dir=out_dir,
|
out_dir=out_dir,
|
||||||
path_template=path_template,
|
path_template=path_template,
|
||||||
stream_format=stream_format,
|
stream_format='flv',
|
||||||
quality_number=quality_number,
|
quality_number=quality_number,
|
||||||
buffer_size=buffer_size,
|
buffer_size=buffer_size,
|
||||||
read_timeout=read_timeout,
|
read_timeout=read_timeout,
|
||||||
@ -73,6 +55,7 @@ class FLVStreamRecorder(
|
|||||||
desc='Recording',
|
desc='Recording',
|
||||||
unit='B',
|
unit='B',
|
||||||
unit_scale=True,
|
unit_scale=True,
|
||||||
|
unit_divisor=1024,
|
||||||
postfix=self._make_pbar_postfix(),
|
postfix=self._make_pbar_postfix(),
|
||||||
) as progress_bar:
|
) as progress_bar:
|
||||||
self._progress_bar = progress_bar
|
self._progress_bar = progress_bar
|
||||||
@ -116,43 +99,6 @@ class FLVStreamRecorder(
|
|||||||
self._emit_event('stream_recording_stopped')
|
self._emit_event('stream_recording_stopped')
|
||||||
logger.debug('Stream recorder thread 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:
|
def _streaming_loop(self) -> None:
|
||||||
url = self._get_live_stream_url()
|
url = self._get_live_stream_url()
|
||||||
|
|
@ -1,68 +1,57 @@
|
|||||||
import io
|
import io
|
||||||
import time
|
|
||||||
import errno
|
|
||||||
import logging
|
import logging
|
||||||
from queue import Queue, Empty
|
import time
|
||||||
from threading import Thread, Event, Lock, Condition
|
|
||||||
from datetime import datetime
|
|
||||||
from contextlib import suppress
|
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 urllib.parse import urlparse
|
||||||
|
|
||||||
from typing import List, Set, Optional
|
|
||||||
|
|
||||||
import urllib3
|
|
||||||
import requests
|
|
||||||
import m3u8
|
import m3u8
|
||||||
|
import requests
|
||||||
|
import urllib3
|
||||||
from m3u8.model import Segment
|
from m3u8.model import Segment
|
||||||
from tqdm import tqdm
|
from ordered_set import OrderedSet
|
||||||
from tenacity import (
|
from tenacity import (
|
||||||
retry,
|
RetryError,
|
||||||
wait_exponential,
|
|
||||||
stop_after_delay,
|
|
||||||
retry_if_result,
|
|
||||||
retry_if_exception_type,
|
|
||||||
retry_if_not_exception_type,
|
|
||||||
Retrying,
|
Retrying,
|
||||||
TryAgain,
|
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.live import Live
|
||||||
from ..bili.typing import StreamFormat, QualityNumber
|
from ..bili.typing import QualityNumber
|
||||||
from ..flv.stream_processor import StreamProcessor
|
|
||||||
from ..flv.exceptions import FlvDataError, FlvStreamCorruptedError
|
from ..flv.exceptions import FlvDataError, FlvStreamCorruptedError
|
||||||
from ..utils.mixins import (
|
from ..flv.stream_processor import StreamProcessor
|
||||||
AsyncCooperationMixin, AsyncStoppableMixin, SupportDebugMixin
|
from ..utils.mixins import SupportDebugMixin
|
||||||
)
|
from .stream_analyzer import StreamProfile, ffprobe
|
||||||
from ..bili.exceptions import (
|
from .stream_recorder_impl import StreamProxy, StreamRecorderImpl
|
||||||
LiveRoomHidden, LiveRoomLocked, LiveRoomEncrypted, NoStreamAvailable,
|
from .stream_remuxer import StreamRemuxer
|
||||||
)
|
|
||||||
|
|
||||||
|
__all__ = 'HLSStreamRecorderImpl',
|
||||||
__all__ = 'HLSStreamRecorder',
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class HLSStreamRecorder(
|
class FailedToFetchSegments(Exception):
|
||||||
BaseStreamRecorder,
|
pass
|
||||||
AsyncCooperationMixin,
|
|
||||||
AsyncStoppableMixin,
|
|
||||||
SupportDebugMixin,
|
class HLSStreamRecorderImpl(StreamRecorderImpl, SupportDebugMixin):
|
||||||
):
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
live: Live,
|
live: Live,
|
||||||
out_dir: str,
|
out_dir: str,
|
||||||
path_template: str,
|
path_template: str,
|
||||||
*,
|
*,
|
||||||
stream_format: StreamFormat = 'flv',
|
|
||||||
quality_number: QualityNumber = 10000,
|
quality_number: QualityNumber = 10000,
|
||||||
buffer_size: Optional[int] = None,
|
buffer_size: Optional[int] = None,
|
||||||
read_timeout: Optional[int] = None,
|
read_timeout: Optional[int] = None,
|
||||||
@ -74,7 +63,7 @@ class HLSStreamRecorder(
|
|||||||
live=live,
|
live=live,
|
||||||
out_dir=out_dir,
|
out_dir=out_dir,
|
||||||
path_template=path_template,
|
path_template=path_template,
|
||||||
stream_format=stream_format,
|
stream_format='fmp4',
|
||||||
quality_number=quality_number,
|
quality_number=quality_number,
|
||||||
buffer_size=buffer_size,
|
buffer_size=buffer_size,
|
||||||
read_timeout=read_timeout,
|
read_timeout=read_timeout,
|
||||||
@ -87,7 +76,8 @@ class HLSStreamRecorder(
|
|||||||
self._ready_to_fetch_segments = Condition()
|
self._ready_to_fetch_segments = Condition()
|
||||||
self._failed_to_fetch_segments = Event()
|
self._failed_to_fetch_segments = Event()
|
||||||
self._stream_analysed_lock = Lock()
|
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:
|
def _run(self) -> None:
|
||||||
logger.debug('Stream recorder thread started')
|
logger.debug('Stream recorder thread started')
|
||||||
@ -103,7 +93,10 @@ class HLSStreamRecorder(
|
|||||||
self._session = requests.Session()
|
self._session = requests.Session()
|
||||||
self._session.headers.update(self._live.headers)
|
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_queue: Queue[Segment] = Queue(maxsize=1000)
|
||||||
self._segment_data_queue: Queue[bytes] = Queue(maxsize=100)
|
self._segment_data_queue: Queue[bytes] = Queue(maxsize=100)
|
||||||
self._stream_host_available = Event()
|
self._stream_host_available = Event()
|
||||||
@ -139,7 +132,7 @@ class HLSStreamRecorder(
|
|||||||
self._segment_data_feeder_thread.join(timeout=10)
|
self._segment_data_feeder_thread.join(timeout=10)
|
||||||
self._stream_remuxer.stop()
|
self._stream_remuxer.stop()
|
||||||
self._stream_remuxer.raise_for_exception()
|
self._stream_remuxer.raise_for_exception()
|
||||||
self._last_segment_uris.clear()
|
self._last_seg_uris.clear()
|
||||||
del self._segment_queue
|
del self._segment_queue
|
||||||
del self._segment_data_queue
|
del self._segment_data_queue
|
||||||
except TryAgain:
|
except TryAgain:
|
||||||
@ -153,44 +146,6 @@ class HLSStreamRecorder(
|
|||||||
self._emit_event('stream_recording_stopped')
|
self._emit_event('stream_recording_stopped')
|
||||||
logger.debug('Stream recorder thread 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:
|
def _streaming_loop(self) -> None:
|
||||||
url = self._get_live_stream_url()
|
url = self._get_live_stream_url()
|
||||||
|
|
||||||
@ -247,32 +202,29 @@ class HLSStreamRecorder(
|
|||||||
self._stream_analysed = False
|
self._stream_analysed = False
|
||||||
continue
|
continue
|
||||||
|
|
||||||
uris: Set[str] = set()
|
curr_seg_uris: Set[str] = set()
|
||||||
for seg in playlist.segments:
|
for seg in playlist.segments:
|
||||||
uris.add(seg.uri)
|
curr_seg_uris.add(seg.uri)
|
||||||
if seg.uri not in self._last_segment_uris:
|
if seg.uri not in self._last_seg_uris:
|
||||||
self._segment_queue.put(seg, timeout=60)
|
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 (
|
if (
|
||||||
self._last_segment_uris and
|
self._last_seg_uris and
|
||||||
not uris.intersection(self._last_segment_uris)
|
not curr_seg_uris.intersection(self._last_seg_uris)
|
||||||
):
|
):
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'segments broken!\n'
|
'segments broken!\n'
|
||||||
f'last segments: {self._last_segment_uris}\n'
|
f'last segments uris: {self._last_seg_uris}\n'
|
||||||
f'current segments: {uris}'
|
f'current segments uris: {curr_seg_uris}'
|
||||||
)
|
)
|
||||||
with self._stream_analysed_lock:
|
with self._stream_analysed_lock:
|
||||||
self._stream_analysed = False
|
self._stream_analysed = False
|
||||||
|
|
||||||
self._last_segment_uris = uris
|
|
||||||
|
|
||||||
if playlist.is_endlist:
|
if playlist.is_endlist:
|
||||||
logger.debug('playlist ended')
|
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)
|
time.sleep(1)
|
||||||
|
|
||||||
@ -338,7 +290,7 @@ class HLSStreamRecorder(
|
|||||||
break
|
break
|
||||||
except requests.exceptions.ConnectionError as e:
|
except requests.exceptions.ConnectionError as e:
|
||||||
logger.warning(repr(e))
|
logger.warning(repr(e))
|
||||||
self._connection_recovered.wait()
|
self._wait_for_connection_error()
|
||||||
except RetryError as e:
|
except RetryError as e:
|
||||||
logger.warning(repr(e))
|
logger.warning(repr(e))
|
||||||
break
|
break
|
||||||
@ -434,6 +386,7 @@ class HLSStreamRecorder(
|
|||||||
desc='Recording',
|
desc='Recording',
|
||||||
unit='B',
|
unit='B',
|
||||||
unit_scale=True,
|
unit_scale=True,
|
||||||
|
unit_divisor=1024,
|
||||||
postfix=self._make_pbar_postfix(),
|
postfix=self._make_pbar_postfix(),
|
||||||
) as progress_bar:
|
) as progress_bar:
|
||||||
self._progress_bar = progress_bar
|
self._progress_bar = progress_bar
|
@ -12,9 +12,7 @@ from tenacity import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .raw_danmaku_receiver import RawDanmakuReceiver
|
from .raw_danmaku_receiver import RawDanmakuReceiver
|
||||||
from .base_stream_recorder import (
|
from .stream_recorder import StreamRecorder, StreamRecorderEventListener
|
||||||
BaseStreamRecorder, StreamRecorderEventListener
|
|
||||||
)
|
|
||||||
from ..bili.live import Live
|
from ..bili.live import Live
|
||||||
from ..exception import exception_callback, submit_exception
|
from ..exception import exception_callback, submit_exception
|
||||||
from ..event.event_emitter import EventListener, EventEmitter
|
from ..event.event_emitter import EventListener, EventEmitter
|
||||||
@ -45,7 +43,7 @@ class RawDanmakuDumper(
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
live: Live,
|
live: Live,
|
||||||
stream_recorder: BaseStreamRecorder,
|
stream_recorder: StreamRecorder,
|
||||||
danmaku_receiver: RawDanmakuReceiver,
|
danmaku_receiver: RawDanmakuReceiver,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@ -53,13 +51,7 @@ class RawDanmakuDumper(
|
|||||||
self._stream_recorder = stream_recorder
|
self._stream_recorder = stream_recorder
|
||||||
self._receiver = danmaku_receiver
|
self._receiver = danmaku_receiver
|
||||||
|
|
||||||
def change_stream_recorder(
|
self._lock: asyncio.Lock = asyncio.Lock()
|
||||||
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:
|
def _do_enable(self) -> None:
|
||||||
self._stream_recorder.add_listener(self)
|
self._stream_recorder.add_listener(self)
|
||||||
@ -72,10 +64,12 @@ class RawDanmakuDumper(
|
|||||||
async def on_video_file_created(
|
async def on_video_file_created(
|
||||||
self, video_path: str, record_start_time: int
|
self, video_path: str, record_start_time: int
|
||||||
) -> None:
|
) -> None:
|
||||||
|
async with self._lock:
|
||||||
self._path = raw_danmaku_path(video_path)
|
self._path = raw_danmaku_path(video_path)
|
||||||
self._start_dumping()
|
self._start_dumping()
|
||||||
|
|
||||||
async def on_video_file_completed(self, video_path: str) -> None:
|
async def on_video_file_completed(self, video_path: str) -> None:
|
||||||
|
async with self._lock:
|
||||||
await self._stop_dumping()
|
await self._stop_dumping()
|
||||||
|
|
||||||
def _start_dumping(self) -> None:
|
def _start_dumping(self) -> None:
|
||||||
|
@ -1,35 +1,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Iterator, Optional, Type
|
from typing import Iterator, Optional
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
import aiofiles
|
|
||||||
import humanize
|
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.danmaku_client import DanmakuClient
|
||||||
from ..bili.live_monitor import LiveMonitor, LiveEventListener
|
from ..bili.live import Live
|
||||||
from ..bili.typing import StreamFormat, QualityNumber
|
from ..bili.live_monitor import LiveEventListener, LiveMonitor
|
||||||
from ..utils.mixins import AsyncStoppableMixin
|
from ..bili.models import RoomInfo
|
||||||
from ..path import cover_path
|
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 ..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'
|
__all__ = 'RecorderEventListener', 'Recorder'
|
||||||
|
|
||||||
@ -47,29 +40,19 @@ class RecorderEventListener(EventListener):
|
|||||||
async def on_recording_cancelled(self, recorder: Recorder) -> None:
|
async def on_recording_cancelled(self, recorder: Recorder) -> None:
|
||||||
...
|
...
|
||||||
|
|
||||||
async def on_video_file_created(
|
async def on_video_file_created(self, recorder: Recorder, path: str) -> None:
|
||||||
self, recorder: Recorder, path: str
|
|
||||||
) -> None:
|
|
||||||
...
|
...
|
||||||
|
|
||||||
async def on_video_file_completed(
|
async def on_video_file_completed(self, recorder: Recorder, path: str) -> None:
|
||||||
self, recorder: Recorder, path: str
|
|
||||||
) -> None:
|
|
||||||
...
|
...
|
||||||
|
|
||||||
async def on_danmaku_file_created(
|
async def on_danmaku_file_created(self, recorder: Recorder, path: str) -> None:
|
||||||
self, recorder: Recorder, path: str
|
|
||||||
) -> None:
|
|
||||||
...
|
...
|
||||||
|
|
||||||
async def on_danmaku_file_completed(
|
async def on_danmaku_file_completed(self, recorder: Recorder, path: str) -> None:
|
||||||
self, recorder: Recorder, path: str
|
|
||||||
) -> None:
|
|
||||||
...
|
...
|
||||||
|
|
||||||
async def on_raw_danmaku_file_created(
|
async def on_raw_danmaku_file_created(self, recorder: Recorder, path: str) -> None:
|
||||||
self, recorder: Recorder, path: str
|
|
||||||
) -> None:
|
|
||||||
...
|
...
|
||||||
|
|
||||||
async def on_raw_danmaku_file_completed(
|
async def on_raw_danmaku_file_completed(
|
||||||
@ -96,6 +79,7 @@ class Recorder(
|
|||||||
*,
|
*,
|
||||||
stream_format: StreamFormat = 'flv',
|
stream_format: StreamFormat = 'flv',
|
||||||
quality_number: QualityNumber = 10000,
|
quality_number: QualityNumber = 10000,
|
||||||
|
fmp4_stream_timeout: int = 10,
|
||||||
buffer_size: Optional[int] = None,
|
buffer_size: Optional[int] = None,
|
||||||
read_timeout: Optional[int] = None,
|
read_timeout: Optional[int] = None,
|
||||||
disconnection_timeout: Optional[int] = None,
|
disconnection_timeout: Optional[int] = None,
|
||||||
@ -107,6 +91,7 @@ class Recorder(
|
|||||||
record_guard_buy: bool = False,
|
record_guard_buy: bool = False,
|
||||||
record_super_chat: bool = False,
|
record_super_chat: bool = False,
|
||||||
save_cover: bool = False,
|
save_cover: bool = False,
|
||||||
|
cover_save_strategy: CoverSaveStrategy = CoverSaveStrategy.DEFAULT,
|
||||||
save_raw_danmaku: bool = False,
|
save_raw_danmaku: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@ -114,23 +99,18 @@ class Recorder(
|
|||||||
self._live = live
|
self._live = live
|
||||||
self._danmaku_client = danmaku_client
|
self._danmaku_client = danmaku_client
|
||||||
self._live_monitor = live_monitor
|
self._live_monitor = live_monitor
|
||||||
self.save_cover = save_cover
|
|
||||||
self.save_raw_danmaku = save_raw_danmaku
|
self.save_raw_danmaku = save_raw_danmaku
|
||||||
|
|
||||||
self._recording: bool = False
|
self._recording: bool = False
|
||||||
self._stream_available: bool = False
|
self._stream_available: bool = False
|
||||||
|
|
||||||
cls: Type[BaseStreamRecorder]
|
self._stream_recorder = StreamRecorder(
|
||||||
if stream_format == 'flv':
|
|
||||||
cls = FLVStreamRecorder
|
|
||||||
else:
|
|
||||||
cls = HLSStreamRecorder
|
|
||||||
self._stream_recorder = cls(
|
|
||||||
self._live,
|
self._live,
|
||||||
out_dir=out_dir,
|
out_dir=out_dir,
|
||||||
path_template=path_template,
|
path_template=path_template,
|
||||||
stream_format=stream_format,
|
stream_format=stream_format,
|
||||||
quality_number=quality_number,
|
quality_number=quality_number,
|
||||||
|
fmp4_stream_timeout=fmp4_stream_timeout,
|
||||||
buffer_size=buffer_size,
|
buffer_size=buffer_size,
|
||||||
read_timeout=read_timeout,
|
read_timeout=read_timeout,
|
||||||
disconnection_timeout=disconnection_timeout,
|
disconnection_timeout=disconnection_timeout,
|
||||||
@ -151,9 +131,14 @@ class Recorder(
|
|||||||
)
|
)
|
||||||
self._raw_danmaku_receiver = RawDanmakuReceiver(danmaku_client)
|
self._raw_danmaku_receiver = RawDanmakuReceiver(danmaku_client)
|
||||||
self._raw_danmaku_dumper = RawDanmakuDumper(
|
self._raw_danmaku_dumper = RawDanmakuDumper(
|
||||||
|
self._live, self._stream_recorder, self._raw_danmaku_receiver
|
||||||
|
)
|
||||||
|
|
||||||
|
self._cover_downloader = CoverDownloader(
|
||||||
self._live,
|
self._live,
|
||||||
self._stream_recorder,
|
self._stream_recorder,
|
||||||
self._raw_danmaku_receiver,
|
save_cover=save_cover,
|
||||||
|
cover_save_strategy=cover_save_strategy,
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -181,11 +166,19 @@ class Recorder(
|
|||||||
self._stream_recorder.quality_number = value
|
self._stream_recorder.quality_number = value
|
||||||
|
|
||||||
@property
|
@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
|
return self._stream_recorder.real_stream_format
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def real_quality_number(self) -> QualityNumber:
|
def real_quality_number(self) -> Optional[QualityNumber]:
|
||||||
return self._stream_recorder.real_quality_number
|
return self._stream_recorder.real_quality_number
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -252,6 +245,22 @@ class Recorder(
|
|||||||
def record_super_chat(self, value: bool) -> None:
|
def record_super_chat(self, value: bool) -> None:
|
||||||
self._danmaku_dumper.record_super_chat = value
|
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
|
@property
|
||||||
def stream_url(self) -> str:
|
def stream_url(self) -> str:
|
||||||
return self._stream_recorder.stream_url
|
return self._stream_recorder.stream_url
|
||||||
@ -375,15 +384,11 @@ class Recorder(
|
|||||||
self._print_changed_room_info(room_info)
|
self._print_changed_room_info(room_info)
|
||||||
self._stream_recorder.update_progress_bar_info()
|
self._stream_recorder.update_progress_bar_info()
|
||||||
|
|
||||||
async def on_video_file_created(
|
async def on_video_file_created(self, path: str, record_start_time: int) -> None:
|
||||||
self, path: str, record_start_time: int
|
|
||||||
) -> None:
|
|
||||||
await self._emit('video_file_created', self, path)
|
await self._emit('video_file_created', self, path)
|
||||||
|
|
||||||
async def on_video_file_completed(self, path: str) -> None:
|
async def on_video_file_completed(self, path: str) -> None:
|
||||||
await self._emit('video_file_completed', self, path)
|
await self._emit('video_file_completed', self, path)
|
||||||
if self.save_cover:
|
|
||||||
await self._save_cover_image(path)
|
|
||||||
|
|
||||||
async def on_danmaku_file_created(self, path: str) -> None:
|
async def on_danmaku_file_created(self, path: str) -> None:
|
||||||
await self._emit('danmaku_file_created', self, path)
|
await self._emit('danmaku_file_created', self, path)
|
||||||
@ -426,7 +431,6 @@ class Recorder(
|
|||||||
async def _start_recording(self) -> None:
|
async def _start_recording(self) -> None:
|
||||||
if self._recording:
|
if self._recording:
|
||||||
return
|
return
|
||||||
self._change_stream_recorder()
|
|
||||||
self._recording = True
|
self._recording = True
|
||||||
|
|
||||||
if self.save_raw_danmaku:
|
if self.save_raw_danmaku:
|
||||||
@ -434,6 +438,7 @@ class Recorder(
|
|||||||
self._raw_danmaku_receiver.start()
|
self._raw_danmaku_receiver.start()
|
||||||
self._danmaku_dumper.enable()
|
self._danmaku_dumper.enable()
|
||||||
self._danmaku_receiver.start()
|
self._danmaku_receiver.start()
|
||||||
|
self._cover_downloader.enable()
|
||||||
|
|
||||||
await self._prepare()
|
await self._prepare()
|
||||||
if self._stream_available:
|
if self._stream_available:
|
||||||
@ -455,6 +460,7 @@ class Recorder(
|
|||||||
self._raw_danmaku_receiver.stop()
|
self._raw_danmaku_receiver.stop()
|
||||||
self._danmaku_dumper.disable()
|
self._danmaku_dumper.disable()
|
||||||
self._danmaku_receiver.stop()
|
self._danmaku_receiver.stop()
|
||||||
|
self._cover_downloader.disable()
|
||||||
|
|
||||||
if self._stopped:
|
if self._stopped:
|
||||||
logger.info('Recording Cancelled')
|
logger.info('Recording Cancelled')
|
||||||
@ -492,60 +498,6 @@ class Recorder(
|
|||||||
if not self._stream_recorder.stopped:
|
if not self._stream_recorder.stopped:
|
||||||
await self.stop()
|
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:
|
def _print_waiting_message(self) -> None:
|
||||||
logger.info('Waiting... until the live starts')
|
logger.info('Waiting... until the live starts')
|
||||||
|
|
||||||
@ -554,9 +506,7 @@ class Recorder(
|
|||||||
user_info = self._live.user_info
|
user_info = self._live.user_info
|
||||||
|
|
||||||
if room_info.live_start_time > 0:
|
if room_info.live_start_time > 0:
|
||||||
live_start_time = str(
|
live_start_time = str(datetime.fromtimestamp(room_info.live_start_time))
|
||||||
datetime.fromtimestamp(room_info.live_start_time)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
live_start_time = 'NULL'
|
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 io
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from threading import Thread, Event
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, BinaryIO, Dict, Iterator, Optional, Tuple
|
from threading import Thread
|
||||||
|
from typing import Any, BinaryIO, Dict, Final, Iterator, Optional, Tuple
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import urllib3
|
import urllib3
|
||||||
from tqdm import tqdm
|
|
||||||
from rx.subject import Subject
|
|
||||||
from rx.core import Observable
|
from rx.core import Observable
|
||||||
|
from rx.subject import Subject
|
||||||
from tenacity import (
|
from tenacity import (
|
||||||
|
Retrying,
|
||||||
|
TryAgain,
|
||||||
retry,
|
retry,
|
||||||
wait_none,
|
retry_if_exception_type,
|
||||||
wait_fixed,
|
retry_if_not_exception_type,
|
||||||
|
retry_if_result,
|
||||||
|
stop_after_attempt,
|
||||||
|
stop_after_delay,
|
||||||
wait_chain,
|
wait_chain,
|
||||||
wait_exponential,
|
wait_exponential,
|
||||||
stop_after_delay,
|
wait_fixed,
|
||||||
stop_after_attempt,
|
wait_none,
|
||||||
retry_if_exception_type,
|
|
||||||
TryAgain,
|
|
||||||
)
|
)
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
from .. import __version__, __prog__, __github__
|
from .. import __github__, __prog__, __version__
|
||||||
from .stream_remuxer import StreamRemuxer
|
from ..bili.exceptions import (
|
||||||
from .stream_analyzer import StreamProfile
|
LiveRoomEncrypted,
|
||||||
from .statistics import StatisticsCalculator
|
LiveRoomHidden,
|
||||||
from ..event.event_emitter import EventListener, EventEmitter
|
LiveRoomLocked,
|
||||||
from ..bili.live import Live
|
NoStreamAvailable,
|
||||||
from ..bili.typing import ApiPlatform, StreamFormat, QualityNumber
|
NoStreamFormatAvailable,
|
||||||
|
NoStreamQualityAvailable,
|
||||||
|
)
|
||||||
from ..bili.helpers import get_quality_name
|
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.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.io import wait_for
|
||||||
from ..utils.mixins import AsyncCooperationMixin, AsyncStoppableMixin
|
from ..utils.mixins import AsyncCooperationMixin, AsyncStoppableMixin
|
||||||
from ..path import escape_path
|
from .retry import before_sleep_log, wait_exponential_for_same_exceptions
|
||||||
from ..logging.room_id import aio_task_with_room_id
|
from .statistics import StatisticsCalculator
|
||||||
from ..bili.exceptions import (
|
from .stream_analyzer import StreamProfile
|
||||||
NoStreamFormatAvailable, NoStreamCodecAvailable, NoStreamQualityAvailable,
|
from .stream_remuxer import StreamRemuxer
|
||||||
)
|
|
||||||
|
|
||||||
|
__all__ = 'StreamRecorderImpl',
|
||||||
__all__ = 'BaseStreamRecorder', 'StreamRecorderEventListener', 'StreamProxy'
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -67,7 +75,7 @@ class StreamRecorderEventListener(EventListener):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
class BaseStreamRecorder(
|
class StreamRecorderImpl(
|
||||||
EventEmitter[StreamRecorderEventListener],
|
EventEmitter[StreamRecorderEventListener],
|
||||||
AsyncCooperationMixin,
|
AsyncCooperationMixin,
|
||||||
AsyncStoppableMixin,
|
AsyncStoppableMixin,
|
||||||
@ -99,9 +107,8 @@ class BaseStreamRecorder(
|
|||||||
live, out_dir, path_template, buffer_size
|
live, out_dir, path_template, buffer_size
|
||||||
)
|
)
|
||||||
|
|
||||||
self._stream_format = stream_format
|
self._stream_format: Final = stream_format
|
||||||
self._quality_number = quality_number
|
self._quality_number = quality_number
|
||||||
self._real_stream_format: Optional[StreamFormat] = None
|
|
||||||
self._real_quality_number: Optional[QualityNumber] = None
|
self._real_quality_number: Optional[QualityNumber] = None
|
||||||
self._api_platform: ApiPlatform = 'android'
|
self._api_platform: ApiPlatform = 'android'
|
||||||
self._use_alternative_stream: bool = False
|
self._use_alternative_stream: bool = False
|
||||||
@ -116,8 +123,6 @@ class BaseStreamRecorder(
|
|||||||
self._stream_host: str = ''
|
self._stream_host: str = ''
|
||||||
self._stream_profile: StreamProfile = {}
|
self._stream_profile: StreamProfile = {}
|
||||||
|
|
||||||
self._connection_recovered = Event()
|
|
||||||
|
|
||||||
def on_file_created(args: Tuple[str, int]) -> None:
|
def on_file_created(args: Tuple[str, int]) -> None:
|
||||||
logger.info(f"Video file created: '{args[0]}'")
|
logger.info(f"Video file created: '{args[0]}'")
|
||||||
self._emit_event('video_file_created', *args)
|
self._emit_event('video_file_created', *args)
|
||||||
@ -177,11 +182,6 @@ class BaseStreamRecorder(
|
|||||||
def stream_format(self) -> StreamFormat:
|
def stream_format(self) -> StreamFormat:
|
||||||
return self._stream_format
|
return self._stream_format
|
||||||
|
|
||||||
@stream_format.setter
|
|
||||||
def stream_format(self, value: StreamFormat) -> None:
|
|
||||||
self._stream_format = value
|
|
||||||
self._real_stream_format = None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def quality_number(self) -> QualityNumber:
|
def quality_number(self) -> QualityNumber:
|
||||||
return self._quality_number
|
return self._quality_number
|
||||||
@ -189,15 +189,12 @@ class BaseStreamRecorder(
|
|||||||
@quality_number.setter
|
@quality_number.setter
|
||||||
def quality_number(self, value: QualityNumber) -> None:
|
def quality_number(self, value: QualityNumber) -> None:
|
||||||
self._quality_number = value
|
self._quality_number = value
|
||||||
self._real_quality_number = None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def real_stream_format(self) -> StreamFormat:
|
def real_quality_number(self) -> Optional[QualityNumber]:
|
||||||
return self._real_stream_format or self.stream_format
|
if self.stopped:
|
||||||
|
return None
|
||||||
@property
|
return self._real_quality_number
|
||||||
def real_quality_number(self) -> QualityNumber:
|
|
||||||
return self._real_quality_number or self.quality_number
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filesize_limit(self) -> int:
|
def filesize_limit(self) -> int:
|
||||||
@ -263,16 +260,20 @@ class BaseStreamRecorder(
|
|||||||
if self._progress_bar is not None:
|
if self._progress_bar is not None:
|
||||||
self._progress_bar.set_postfix_str(self._make_pbar_postfix())
|
self._progress_bar.set_postfix_str(self._make_pbar_postfix())
|
||||||
|
|
||||||
async def _do_start(self) -> None:
|
def _reset(self) -> None:
|
||||||
logger.debug('Starting stream recorder...')
|
|
||||||
self._dl_calculator.reset()
|
self._dl_calculator.reset()
|
||||||
self._rec_calculator.reset()
|
self._rec_calculator.reset()
|
||||||
self._stream_url = ''
|
self._stream_url = ''
|
||||||
self._stream_host = ''
|
self._stream_host = ''
|
||||||
self._stream_profile = {}
|
self._stream_profile = {}
|
||||||
self._api_platform = 'android'
|
self._api_platform = 'android'
|
||||||
|
self._real_quality_number = None
|
||||||
self._use_alternative_stream = False
|
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(
|
self._thread = Thread(
|
||||||
target=self._run, name=f'StreamRecorder::{self._live.room_id}'
|
target=self._run, name=f'StreamRecorder::{self._live.room_id}'
|
||||||
)
|
)
|
||||||
@ -290,6 +291,44 @@ class BaseStreamRecorder(
|
|||||||
def _run(self) -> None:
|
def _run(self) -> None:
|
||||||
raise NotImplementedError()
|
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:
|
def _rotate_api_platform(self) -> None:
|
||||||
if self._api_platform == 'android':
|
if self._api_platform == 'android':
|
||||||
self._api_platform = 'web'
|
self._api_platform = 'web'
|
||||||
@ -305,8 +344,8 @@ class BaseStreamRecorder(
|
|||||||
stop=stop_after_attempt(300),
|
stop=stop_after_attempt(300),
|
||||||
)
|
)
|
||||||
def _get_live_stream_url(self) -> str:
|
def _get_live_stream_url(self) -> str:
|
||||||
|
fmt = self._stream_format
|
||||||
qn = self._real_quality_number or self.quality_number
|
qn = self._real_quality_number or self.quality_number
|
||||||
fmt = self._real_stream_format or self.stream_format
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f'Getting the live stream url... qn: {qn}, format: {fmt}, '
|
f'Getting the live stream url... qn: {qn}, format: {fmt}, '
|
||||||
f'api platform: {self._api_platform}, '
|
f'api platform: {self._api_platform}, '
|
||||||
@ -327,35 +366,11 @@ class BaseStreamRecorder(
|
|||||||
)
|
)
|
||||||
self._real_quality_number = 10000
|
self._real_quality_number = 10000
|
||||||
raise TryAgain
|
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:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
f'Adopted the stream format ({fmt}) and quality ({qn})'
|
f'Adopted the stream format ({fmt}) and quality ({qn})'
|
||||||
)
|
)
|
||||||
self._real_quality_number = qn
|
self._real_quality_number = qn
|
||||||
self._real_stream_format = fmt
|
|
||||||
|
|
||||||
if not self._use_alternative_stream:
|
if not self._use_alternative_stream:
|
||||||
url = urls[0]
|
url = urls[0]
|
||||||
@ -366,8 +381,9 @@ class BaseStreamRecorder(
|
|||||||
self._use_alternative_stream = False
|
self._use_alternative_stream = False
|
||||||
self._rotate_api_platform()
|
self._rotate_api_platform()
|
||||||
logger.info(
|
logger.info(
|
||||||
'No alternative stream url available, will using the primary'
|
'No alternative stream url available, '
|
||||||
f' stream url from {self._api_platform} api instead.'
|
'will using the primary stream url '
|
||||||
|
f'from {self._api_platform} api instead.'
|
||||||
)
|
)
|
||||||
raise TryAgain
|
raise TryAgain
|
||||||
logger.info(f"Got live stream url: '{url}'")
|
logger.info(f"Got live stream url: '{url}'")
|
||||||
@ -380,16 +396,7 @@ class BaseStreamRecorder(
|
|||||||
logger.debug(f'Retry {name} after {seconds} seconds')
|
logger.debug(f'Retry {name} after {seconds} seconds')
|
||||||
time.sleep(seconds)
|
time.sleep(seconds)
|
||||||
|
|
||||||
def _wait_for_connection_error(self) -> None:
|
def _wait_for_connection_error(self, check_interval: int = 3) -> 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:
|
|
||||||
timeout = self.disconnection_timeout
|
timeout = self.disconnection_timeout
|
||||||
logger.info(f'Waiting {timeout} seconds for connection recovery... ')
|
logger.info(f'Waiting {timeout} seconds for connection recovery... ')
|
||||||
timebase = time.monotonic()
|
timebase = time.monotonic()
|
||||||
@ -397,11 +404,10 @@ class BaseStreamRecorder(
|
|||||||
if timeout is not None and time.monotonic() - timebase > timeout:
|
if timeout is not None and time.monotonic() - timebase > timeout:
|
||||||
logger.error(f'Connection not recovered in {timeout} seconds')
|
logger.error(f'Connection not recovered in {timeout} seconds')
|
||||||
self._stopped = True
|
self._stopped = True
|
||||||
self._connection_recovered.set()
|
break
|
||||||
time.sleep(check_interval)
|
time.sleep(check_interval)
|
||||||
else:
|
else:
|
||||||
logger.info('Connection recovered')
|
logger.info('Connection recovered')
|
||||||
self._connection_recovered.set()
|
|
||||||
|
|
||||||
def _make_pbar_postfix(self) -> str:
|
def _make_pbar_postfix(self) -> str:
|
||||||
return '{room_id} - {user_name}: {room_title}'.format(
|
return '{room_id} - {user_name}: {room_title}'.format(
|
||||||
@ -434,7 +440,7 @@ B站直播录像
|
|||||||
房间号:{self._live.room_info.room_id}
|
房间号:{self._live.room_info.room_id}
|
||||||
开播时间:{live_start_time}
|
开播时间:{live_start_time}
|
||||||
流主机: {self._stream_host}
|
流主机: {self._stream_host}
|
||||||
流格式:{self._real_stream_format}
|
流格式:{self._stream_format}
|
||||||
流画质:{stream_quality}
|
流画质:{stream_quality}
|
||||||
录制程序:{__prog__} v{__version__} {__github__}''',
|
录制程序:{__prog__} v{__version__} {__github__}''',
|
||||||
'description': OrderedDict({
|
'description': OrderedDict({
|
||||||
@ -446,7 +452,7 @@ B站直播录像
|
|||||||
'ParentArea': self._live.room_info.parent_area_name,
|
'ParentArea': self._live.room_info.parent_area_name,
|
||||||
'LiveStartTime': str(live_start_time),
|
'LiveStartTime': str(live_start_time),
|
||||||
'StreamHost': self._stream_host,
|
'StreamHost': self._stream_host,
|
||||||
'StreamFormat': self._real_stream_format,
|
'StreamFormat': self._stream_format,
|
||||||
'StreamQuality': stream_quality,
|
'StreamQuality': stream_quality,
|
||||||
'Recorder': f'{__prog__} v{__version__} {__github__}',
|
'Recorder': f'{__prog__} v{__version__} {__github__}',
|
||||||
})
|
})
|
@ -1,22 +1,20 @@
|
|||||||
import re
|
|
||||||
import os
|
|
||||||
import io
|
|
||||||
import errno
|
import errno
|
||||||
import shlex
|
import io
|
||||||
import logging
|
import logging
|
||||||
from threading import Thread, Condition
|
import os
|
||||||
from subprocess import Popen, PIPE, CalledProcessError
|
import re
|
||||||
|
import shlex
|
||||||
|
from subprocess import PIPE, CalledProcessError, Popen
|
||||||
|
from threading import Condition, Thread
|
||||||
from typing import Optional, cast
|
from typing import Optional, cast
|
||||||
|
|
||||||
|
|
||||||
from ..utils.mixins import StoppableMixin, SupportDebugMixin
|
|
||||||
from ..utils.io import wait_for
|
from ..utils.io import wait_for
|
||||||
|
from ..utils.mixins import StoppableMixin, SupportDebugMixin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
__all__ = 'StreamRemuxer',
|
__all__ = ('StreamRemuxer',)
|
||||||
|
|
||||||
|
|
||||||
class FFmpegError(Exception):
|
class FFmpegError(Exception):
|
||||||
@ -28,10 +26,10 @@ class StreamRemuxer(StoppableMixin, SupportDebugMixin):
|
|||||||
r'\b(error|failed|missing|invalid|corrupt)\b', re.IGNORECASE
|
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__()
|
super().__init__()
|
||||||
self._room_id = room_id
|
self._room_id = room_id
|
||||||
self._bufsize = bufsize
|
self._remove_filler_data = remove_filler_data
|
||||||
self._exception: Optional[Exception] = None
|
self._exception: Optional[Exception] = None
|
||||||
self._ready = Condition()
|
self._ready = Condition()
|
||||||
self._env = None
|
self._env = None
|
||||||
@ -83,9 +81,7 @@ class StreamRemuxer(StoppableMixin, SupportDebugMixin):
|
|||||||
def _do_start(self) -> None:
|
def _do_start(self) -> None:
|
||||||
logger.debug('Starting stream remuxer...')
|
logger.debug('Starting stream remuxer...')
|
||||||
self._thread = Thread(
|
self._thread = Thread(
|
||||||
target=self._run,
|
target=self._run, name=f'StreamRemuxer::{self._room_id}', daemon=True
|
||||||
name=f'StreamRemuxer::{self._room_id}',
|
|
||||||
daemon=True,
|
|
||||||
)
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
@ -124,21 +120,21 @@ class StreamRemuxer(StoppableMixin, SupportDebugMixin):
|
|||||||
logger.debug('Stopped stream remuxer')
|
logger.debug('Stopped stream remuxer')
|
||||||
|
|
||||||
def _run_subprocess(self) -> None:
|
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)
|
args = shlex.split(cmd)
|
||||||
|
|
||||||
with Popen(
|
with Popen(
|
||||||
args, stdin=PIPE, stdout=PIPE, stderr=PIPE,
|
args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=self._env
|
||||||
bufsize=self._bufsize, env=self._env,
|
|
||||||
) as self._subprocess:
|
) as self._subprocess:
|
||||||
with self._ready:
|
with self._ready:
|
||||||
self._ready.notify_all()
|
self._ready.notify_all()
|
||||||
|
|
||||||
assert self._subprocess.stderr is not None
|
assert self._subprocess.stderr is not None
|
||||||
with io.TextIOWrapper(
|
with io.TextIOWrapper(
|
||||||
self._subprocess.stderr,
|
self._subprocess.stderr, encoding='utf-8', errors='backslashreplace'
|
||||||
encoding='utf-8',
|
|
||||||
errors='backslashreplace'
|
|
||||||
) as stderr:
|
) as stderr:
|
||||||
while not self._stopped:
|
while not self._stopped:
|
||||||
line = wait_for(stderr.readline, timeout=10)
|
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>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
<noscript>Please enable JavaScript to continue using this application.</noscript>
|
<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>
|
</body></html>
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"configVersion": 1,
|
"configVersion": 1,
|
||||||
"timestamp": 1651566774775,
|
"timestamp": 1651812628293,
|
||||||
"index": "/index.html",
|
"index": "/index.html",
|
||||||
"assetGroups": [
|
"assetGroups": [
|
||||||
{
|
{
|
||||||
@ -14,15 +14,15 @@
|
|||||||
"/103.5b5d2a6e5a8a7479.js",
|
"/103.5b5d2a6e5a8a7479.js",
|
||||||
"/146.92e3b29c4c754544.js",
|
"/146.92e3b29c4c754544.js",
|
||||||
"/45.c90c3cea2bf1a66e.js",
|
"/45.c90c3cea2bf1a66e.js",
|
||||||
"/474.88f730916af2dc81.js",
|
"/474.c2cc33b068a782fc.js",
|
||||||
"/66.17103bf51c59b5c8.js",
|
"/66.31f5b9ae46ae9005.js",
|
||||||
"/869.ac675e78fa0ea7cf.js",
|
"/869.42b1fd9a88732b97.js",
|
||||||
"/common.858f777e9296e6f2.js",
|
"/common.858f777e9296e6f2.js",
|
||||||
"/index.html",
|
"/index.html",
|
||||||
"/main.b9234f0840c7101a.js",
|
"/main.b9234f0840c7101a.js",
|
||||||
"/manifest.webmanifest",
|
"/manifest.webmanifest",
|
||||||
"/polyfills.4b08448aee19bb22.js",
|
"/polyfills.4b08448aee19bb22.js",
|
||||||
"/runtime.5296fd12ffdfadbe.js",
|
"/runtime.6dfcba40e2a24845.js",
|
||||||
"/styles.1f581691b230dc4d.css"
|
"/styles.1f581691b230dc4d.css"
|
||||||
],
|
],
|
||||||
"patterns": []
|
"patterns": []
|
||||||
@ -1637,9 +1637,9 @@
|
|||||||
"/103.5b5d2a6e5a8a7479.js": "cc0240f217015b6d4ddcc14f31fcc42e1c1c282a",
|
"/103.5b5d2a6e5a8a7479.js": "cc0240f217015b6d4ddcc14f31fcc42e1c1c282a",
|
||||||
"/146.92e3b29c4c754544.js": "3824de681dd1f982ea69a065cdf54d7a1e781f4d",
|
"/146.92e3b29c4c754544.js": "3824de681dd1f982ea69a065cdf54d7a1e781f4d",
|
||||||
"/45.c90c3cea2bf1a66e.js": "e5bfb8cf3803593e6b8ea14c90b3d3cb6a066764",
|
"/45.c90c3cea2bf1a66e.js": "e5bfb8cf3803593e6b8ea14c90b3d3cb6a066764",
|
||||||
"/474.88f730916af2dc81.js": "e7cb3e7bd68c162633d94c8c848dea2daeac8bc3",
|
"/474.c2cc33b068a782fc.js": "cb59c0b560cdceeccf9c17df5e9be76bfa942775",
|
||||||
"/66.17103bf51c59b5c8.js": "67c9bb3ac7e7c7c25ebe1db69fc87890a2fdc184",
|
"/66.31f5b9ae46ae9005.js": "cc22d2582d8e4c2a83e089d5a1ec32619e439ccd",
|
||||||
"/869.ac675e78fa0ea7cf.js": "f45052016cb5201d5784b3f261e719d96bd1b153",
|
"/869.42b1fd9a88732b97.js": "ca5c951f04d02218b3fe7dc5c022dad22bf36eca",
|
||||||
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
|
"/assets/animal/panda.js": "fec2868bb3053dd2da45f96bbcb86d5116ed72b1",
|
||||||
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
|
"/assets/animal/panda.svg": "bebd302cdc601e0ead3a6d2710acf8753f3d83b1",
|
||||||
"/assets/fill/.gitkeep": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
|
"/assets/fill/.gitkeep": "da39a3ee5e6b4b0d3255bfef95601890afd80709",
|
||||||
@ -3234,11 +3234,11 @@
|
|||||||
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
|
"/assets/twotone/warning.js": "fb2d7ea232f3a99bf8f080dbc94c65699232ac01",
|
||||||
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
|
"/assets/twotone/warning.svg": "8c7a2d3e765a2e7dd58ac674870c6655cecb0068",
|
||||||
"/common.858f777e9296e6f2.js": "b68ca68e1e214a2537d96935c23410126cc564dd",
|
"/common.858f777e9296e6f2.js": "b68ca68e1e214a2537d96935c23410126cc564dd",
|
||||||
"/index.html": "4957097d609200632fe355aef8f7603a3bb1addc",
|
"/index.html": "02b6c1c31185bec91e297c4f224b2d193f9c981c",
|
||||||
"/main.b9234f0840c7101a.js": "c8c7b588c070b957a2659f62d6a77de284aa2233",
|
"/main.b9234f0840c7101a.js": "c8c7b588c070b957a2659f62d6a77de284aa2233",
|
||||||
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
|
"/manifest.webmanifest": "62c1cb8c5ad2af551a956b97013ab55ce77dd586",
|
||||||
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
|
"/polyfills.4b08448aee19bb22.js": "8e73f2d42cc13ca353cea5c886d930bd6da08d0d",
|
||||||
"/runtime.5296fd12ffdfadbe.js": "5b84a91028ab9e0daaaf89d70f2d12c48d5e358e",
|
"/runtime.6dfcba40e2a24845.js": "9dc2eec70103e3ee2249dde68eeb09910a7ae02d",
|
||||||
"/styles.1f581691b230dc4d.css": "6f5befbbad57c2b2e80aae855139744b8010d150"
|
"/styles.1f581691b230dc4d.css": "6f5befbbad57c2b2e80aae855139744b8010d150"
|
||||||
},
|
},
|
||||||
"navigationUrls": [
|
"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
|
yield tag
|
||||||
if tag.timestamp > 0:
|
if tag.timestamp > 0:
|
||||||
break
|
break
|
||||||
|
else:
|
||||||
|
raise EOFError('no tags')
|
||||||
|
|
||||||
start = tag.timestamp
|
start = tag.timestamp
|
||||||
end = start + duration
|
end = start + duration
|
||||||
|
@ -381,11 +381,26 @@ class StreamProcessor:
|
|||||||
bytes_io = io.BytesIO()
|
bytes_io = io.BytesIO()
|
||||||
writer = FlvWriter(bytes_io)
|
writer = FlvWriter(bytes_io)
|
||||||
writer.write_header(flv_header)
|
writer.write_header(flv_header)
|
||||||
writer.write_tag(self._parameters_checker.last_metadata_tag)
|
writer.write_tag(
|
||||||
writer.write_tag(self._parameters_checker.last_video_header_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:
|
if self._parameters_checker.last_audio_header_tag is not None:
|
||||||
writer.write_tag(self._parameters_checker.last_audio_header_tag)
|
writer.write_tag(
|
||||||
writer.write_tag(first_data_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:
|
def on_next(profile: StreamProfile) -> None:
|
||||||
self._stream_profile_updates.on_next(profile)
|
self._stream_profile_updates.on_next(profile)
|
||||||
|
@ -5,7 +5,7 @@ import ssl
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from http.client import HTTPException
|
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
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@ -192,7 +192,7 @@ class Pushplus(MessagingProvider):
|
|||||||
|
|
||||||
class TelegramResponse(TypedDict):
|
class TelegramResponse(TypedDict):
|
||||||
ok: bool
|
ok: bool
|
||||||
result: dict
|
result: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
class Telegram(MessagingProvider):
|
class Telegram(MessagingProvider):
|
||||||
|
@ -245,6 +245,7 @@ class Postprocessor(
|
|||||||
out_path,
|
out_path,
|
||||||
metadata_path,
|
metadata_path,
|
||||||
report_progress=True,
|
report_progress=True,
|
||||||
|
remove_filler_data=True,
|
||||||
).subscribe(
|
).subscribe(
|
||||||
on_next,
|
on_next,
|
||||||
lambda e: future.set_exception(e),
|
lambda e: future.set_exception(e),
|
||||||
|
@ -61,12 +61,20 @@ class VideoRemuxer:
|
|||||||
in_path: str,
|
in_path: str,
|
||||||
out_path: str,
|
out_path: str,
|
||||||
metadata_path: Optional[str] = None,
|
metadata_path: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
remove_filler_data: bool = False,
|
||||||
) -> RemuxResult:
|
) -> RemuxResult:
|
||||||
|
cmd = f'ffmpeg -i "{in_path}"'
|
||||||
if metadata_path is not None:
|
if metadata_path is not None:
|
||||||
cmd = f'ffmpeg -i "{in_path}" -i "{metadata_path}" ' \
|
cmd += f' -i "{metadata_path}" -map_metadata 1'
|
||||||
f'-map_metadata 1 -codec copy "{out_path}" -y'
|
cmd += ' -codec copy'
|
||||||
else:
|
if remove_filler_data:
|
||||||
cmd = f'ffmpeg -i "{in_path}" -codec copy "{out_path}" -y'
|
# 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)
|
args = shlex.split(cmd)
|
||||||
out_lines: List[str] = []
|
out_lines: List[str] = []
|
||||||
@ -140,6 +148,7 @@ def remux_video(
|
|||||||
metadata_path: Optional[str] = None,
|
metadata_path: Optional[str] = None,
|
||||||
*,
|
*,
|
||||||
report_progress: bool = False,
|
report_progress: bool = False,
|
||||||
|
remove_filler_data: bool = False,
|
||||||
) -> Observable:
|
) -> Observable:
|
||||||
def subscribe(
|
def subscribe(
|
||||||
observer: Observer[Union[RemuxProgress, RemuxResult]],
|
observer: Observer[Union[RemuxProgress, RemuxResult]],
|
||||||
@ -167,7 +176,12 @@ def remux_video(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
observer.on_error(e)
|
observer.on_error(e)
|
||||||
else:
|
else:
|
||||||
|
@ -20,6 +20,7 @@ from pydantic.networks import HttpUrl, EmailStr
|
|||||||
|
|
||||||
from ..bili.typing import StreamFormat, QualityNumber
|
from ..bili.typing import StreamFormat, QualityNumber
|
||||||
from ..postprocess import DeleteStrategy
|
from ..postprocess import DeleteStrategy
|
||||||
|
from ..core.cover_downloader import CoverSaveStrategy
|
||||||
from ..logging.typing import LOG_LEVEL
|
from ..logging.typing import LOG_LEVEL
|
||||||
from ..utils.string import camel_case
|
from ..utils.string import camel_case
|
||||||
|
|
||||||
@ -144,12 +145,21 @@ class DanmakuSettings(DanmakuOptions):
|
|||||||
class RecorderOptions(BaseModel):
|
class RecorderOptions(BaseModel):
|
||||||
stream_format: Optional[StreamFormat]
|
stream_format: Optional[StreamFormat]
|
||||||
quality_number: Optional[QualityNumber]
|
quality_number: Optional[QualityNumber]
|
||||||
|
fmp4_stream_timeout: Optional[int]
|
||||||
read_timeout: Optional[int] # seconds
|
read_timeout: Optional[int] # seconds
|
||||||
disconnection_timeout: Optional[int] # seconds
|
disconnection_timeout: Optional[int] # seconds
|
||||||
buffer_size: Annotated[ # bytes
|
buffer_size: Annotated[ # bytes
|
||||||
Optional[int], Field(ge=4096, le=1024 ** 2 * 512, multiple_of=2)
|
Optional[int], Field(ge=4096, le=1024 ** 2 * 512, multiple_of=2)
|
||||||
]
|
]
|
||||||
save_cover: Optional[bool]
|
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')
|
@validator('read_timeout')
|
||||||
def _validate_read_timeout(cls, value: Optional[int]) -> Optional[int]:
|
def _validate_read_timeout(cls, value: Optional[int]) -> Optional[int]:
|
||||||
@ -171,12 +181,14 @@ class RecorderOptions(BaseModel):
|
|||||||
class RecorderSettings(RecorderOptions):
|
class RecorderSettings(RecorderOptions):
|
||||||
stream_format: StreamFormat = 'flv'
|
stream_format: StreamFormat = 'flv'
|
||||||
quality_number: QualityNumber = 20000 # 4K, the highest quality.
|
quality_number: QualityNumber = 20000 # 4K, the highest quality.
|
||||||
|
fmp4_stream_timeout: int = 10
|
||||||
read_timeout: int = 3
|
read_timeout: int = 3
|
||||||
disconnection_timeout: int = 600
|
disconnection_timeout: int = 600
|
||||||
buffer_size: Annotated[
|
buffer_size: Annotated[
|
||||||
int, Field(ge=4096, le=1024 ** 2 * 512, multiple_of=2)
|
int, Field(ge=4096, le=1024 ** 2 * 512, multiple_of=2)
|
||||||
] = 8192
|
] = 8192
|
||||||
save_cover: bool = False
|
save_cover: bool = False
|
||||||
|
cover_save_strategy: CoverSaveStrategy = CoverSaveStrategy.DEFAULT
|
||||||
|
|
||||||
|
|
||||||
class PostprocessingOptions(BaseModel):
|
class PostprocessingOptions(BaseModel):
|
||||||
@ -465,8 +477,8 @@ class Settings(BaseModel):
|
|||||||
version: str = '1.0'
|
version: str = '1.0'
|
||||||
|
|
||||||
tasks: Annotated[List[TaskSettings], Field(max_items=100)] = []
|
tasks: Annotated[List[TaskSettings], Field(max_items=100)] = []
|
||||||
output: OutputSettings = OutputSettings()
|
output: OutputSettings = OutputSettings() # type: ignore
|
||||||
logging: LoggingSettings = LoggingSettings()
|
logging: LoggingSettings = LoggingSettings() # type: ignore
|
||||||
header: HeaderSettings = HeaderSettings()
|
header: HeaderSettings = HeaderSettings()
|
||||||
danmaku: DanmakuSettings = DanmakuSettings()
|
danmaku: DanmakuSettings = DanmakuSettings()
|
||||||
recorder: RecorderSettings = RecorderSettings()
|
recorder: RecorderSettings = RecorderSettings()
|
||||||
|
@ -6,6 +6,7 @@ import attr
|
|||||||
|
|
||||||
from ..bili.models import RoomInfo, UserInfo
|
from ..bili.models import RoomInfo, UserInfo
|
||||||
from ..bili.typing import StreamFormat, QualityNumber
|
from ..bili.typing import StreamFormat, QualityNumber
|
||||||
|
from ..core.cover_downloader import CoverSaveStrategy
|
||||||
from ..postprocess import DeleteStrategy, PostprocessorStatus
|
from ..postprocess import DeleteStrategy, PostprocessorStatus
|
||||||
from ..postprocess.typing import Progress
|
from ..postprocess.typing import Progress
|
||||||
|
|
||||||
@ -32,8 +33,8 @@ class TaskStatus:
|
|||||||
rec_rate: float # Number of Bytes per second
|
rec_rate: float # Number of Bytes per second
|
||||||
danmu_total: int # Number of Danmu in total
|
danmu_total: int # Number of Danmu in total
|
||||||
danmu_rate: float # Number of Danmu per minutes
|
danmu_rate: float # Number of Danmu per minutes
|
||||||
real_stream_format: StreamFormat
|
real_stream_format: Optional[StreamFormat]
|
||||||
real_quality_number: QualityNumber
|
real_quality_number: Optional[QualityNumber]
|
||||||
recording_path: Optional[str] = None
|
recording_path: Optional[str] = None
|
||||||
postprocessor_status: PostprocessorStatus = PostprocessorStatus.WAITING
|
postprocessor_status: PostprocessorStatus = PostprocessorStatus.WAITING
|
||||||
postprocessing_path: Optional[str] = None
|
postprocessing_path: Optional[str] = None
|
||||||
@ -60,10 +61,12 @@ class TaskParam:
|
|||||||
# RecorderSettings
|
# RecorderSettings
|
||||||
stream_format: StreamFormat
|
stream_format: StreamFormat
|
||||||
quality_number: QualityNumber
|
quality_number: QualityNumber
|
||||||
|
fmp4_stream_timeout: int
|
||||||
read_timeout: int
|
read_timeout: int
|
||||||
disconnection_timeout: Optional[int]
|
disconnection_timeout: Optional[int]
|
||||||
buffer_size: int
|
buffer_size: int
|
||||||
save_cover: bool
|
save_cover: bool
|
||||||
|
cover_save_strategy: CoverSaveStrategy
|
||||||
# PostprocessingOptions
|
# PostprocessingOptions
|
||||||
remux_to_mp4: bool
|
remux_to_mp4: bool
|
||||||
inject_extra_metadata: bool
|
inject_extra_metadata: bool
|
||||||
|
@ -19,6 +19,7 @@ from ..bili.live_monitor import LiveMonitor
|
|||||||
from ..bili.typing import StreamFormat, QualityNumber
|
from ..bili.typing import StreamFormat, QualityNumber
|
||||||
from ..core import Recorder
|
from ..core import Recorder
|
||||||
from ..core.stream_analyzer import StreamProfile
|
from ..core.stream_analyzer import StreamProfile
|
||||||
|
from ..core.cover_downloader import CoverSaveStrategy
|
||||||
from ..postprocess import Postprocessor, PostprocessorStatus, DeleteStrategy
|
from ..postprocess import Postprocessor, PostprocessorStatus, DeleteStrategy
|
||||||
from ..postprocess.remuxer import RemuxProgress
|
from ..postprocess.remuxer import RemuxProgress
|
||||||
from ..flv.metadata_injector import InjectProgress
|
from ..flv.metadata_injector import InjectProgress
|
||||||
@ -257,6 +258,14 @@ class RecordTask:
|
|||||||
def save_cover(self, value: bool) -> None:
|
def save_cover(self, value: bool) -> None:
|
||||||
self._recorder.save_cover = value
|
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
|
@property
|
||||||
def save_raw_danmaku(self) -> bool:
|
def save_raw_danmaku(self) -> bool:
|
||||||
return self._recorder.save_raw_danmaku
|
return self._recorder.save_raw_danmaku
|
||||||
@ -282,11 +291,19 @@ class RecordTask:
|
|||||||
self._recorder.quality_number = value
|
self._recorder.quality_number = value
|
||||||
|
|
||||||
@property
|
@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
|
return self._recorder.real_stream_format
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def real_quality_number(self) -> QualityNumber:
|
def real_quality_number(self) -> Optional[QualityNumber]:
|
||||||
return self._recorder.real_quality_number
|
return self._recorder.real_quality_number
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -285,10 +285,12 @@ class RecordTaskManager:
|
|||||||
task = self._get_task(room_id)
|
task = self._get_task(room_id)
|
||||||
task.stream_format = settings.stream_format
|
task.stream_format = settings.stream_format
|
||||||
task.quality_number = settings.quality_number
|
task.quality_number = settings.quality_number
|
||||||
|
task.fmp4_stream_timeout = settings.fmp4_stream_timeout
|
||||||
task.read_timeout = settings.read_timeout
|
task.read_timeout = settings.read_timeout
|
||||||
task.disconnection_timeout = settings.disconnection_timeout
|
task.disconnection_timeout = settings.disconnection_timeout
|
||||||
task.buffer_size = settings.buffer_size
|
task.buffer_size = settings.buffer_size
|
||||||
task.save_cover = settings.save_cover
|
task.save_cover = settings.save_cover
|
||||||
|
task.cover_save_strategy = settings.cover_save_strategy
|
||||||
|
|
||||||
def apply_task_postprocessing_settings(
|
def apply_task_postprocessing_settings(
|
||||||
self, room_id: int, settings: PostprocessingSettings
|
self, room_id: int, settings: PostprocessingSettings
|
||||||
@ -322,9 +324,11 @@ class RecordTaskManager:
|
|||||||
record_guard_buy=task.record_guard_buy,
|
record_guard_buy=task.record_guard_buy,
|
||||||
record_super_chat=task.record_super_chat,
|
record_super_chat=task.record_super_chat,
|
||||||
save_cover=task.save_cover,
|
save_cover=task.save_cover,
|
||||||
|
cover_save_strategy=task.cover_save_strategy,
|
||||||
save_raw_danmaku=task.save_raw_danmaku,
|
save_raw_danmaku=task.save_raw_danmaku,
|
||||||
stream_format=task.stream_format,
|
stream_format=task.stream_format,
|
||||||
quality_number=task.quality_number,
|
quality_number=task.quality_number,
|
||||||
|
fmp4_stream_timeout=task.fmp4_stream_timeout,
|
||||||
read_timeout=task.read_timeout,
|
read_timeout=task.read_timeout,
|
||||||
disconnection_timeout=task.disconnection_timeout,
|
disconnection_timeout=task.disconnection_timeout,
|
||||||
buffer_size=task.buffer_size,
|
buffer_size=task.buffer_size,
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
<input
|
<input
|
||||||
id="server"
|
id="server"
|
||||||
type="url"
|
type="url"
|
||||||
placeholder="默认为官方服务器 https://api2.pushdeer.com"
|
placeholder="默认为官方服务器:https://api2.pushdeer.com"
|
||||||
nz-input
|
nz-input
|
||||||
formControlName="server"
|
formControlName="server"
|
||||||
/>
|
/>
|
||||||
|
@ -8,15 +8,20 @@
|
|||||||
>
|
>
|
||||||
<ng-template #streamFormatTip>
|
<ng-template #streamFormatTip>
|
||||||
<p>
|
<p>
|
||||||
选择要录制的直播流格式<br />
|
选择要录制的直播流格式
|
||||||
<br />
|
<br />
|
||||||
FLV 网络不稳定容易中断丢失数据 <br />
|
FLV: 网络不稳定容易中断丢失数据或录制到二压画质
|
||||||
HLS (ts) 基本不受本地网络影响 <br />
|
|
||||||
HLS (fmp4) 只有少数直播间支持 <br />
|
|
||||||
<br />
|
<br />
|
||||||
P.S.<br />
|
HLS (fmp4): 基本不受网络波动影响,但只有部分直播间支持。
|
||||||
非 FLV 格式需要 ffmpeg<br />
|
<br />
|
||||||
HLS (fmp4) 不支持会自动切换到 HLS (ts)<br />
|
P.S.
|
||||||
|
<br />
|
||||||
|
录制 HLS 流需要 ffmpeg
|
||||||
|
<br />
|
||||||
|
在设定时间内没有 fmp4 流会自动切换录制 flv 流
|
||||||
|
<br />
|
||||||
|
WEB 端直播播放器是 Hls7Player 的直播间支持录制 fmp4 流, fMp4Player
|
||||||
|
则不支持。
|
||||||
</p>
|
</p>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<nz-form-control
|
<nz-form-control
|
||||||
@ -33,6 +38,39 @@
|
|||||||
</nz-select>
|
</nz-select>
|
||||||
</nz-form-control>
|
</nz-form-control>
|
||||||
</nz-form-item>
|
</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-item class="setting-item">
|
||||||
<nz-form-label
|
<nz-form-label
|
||||||
class="setting-label"
|
class="setting-label"
|
||||||
@ -66,6 +104,41 @@
|
|||||||
<nz-switch formControlName="saveCover"></nz-switch>
|
<nz-switch formControlName="saveCover"></nz-switch>
|
||||||
</nz-form-control>
|
</nz-form-control>
|
||||||
</nz-form-item>
|
</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-item class="setting-item">
|
||||||
<nz-form-label
|
<nz-form-label
|
||||||
class="setting-label"
|
class="setting-label"
|
||||||
@ -84,7 +157,7 @@
|
|||||||
: 'warning'
|
: 'warning'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<nz-select formControlName="readTimeout" [nzOptions]="timeoutOptions">
|
<nz-select formControlName="readTimeout" [nzOptions]="readTimeoutOptions">
|
||||||
</nz-select>
|
</nz-select>
|
||||||
</nz-form-control>
|
</nz-form-control>
|
||||||
</nz-form-item>
|
</nz-form-item>
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
TIMEOUT_OPTIONS,
|
TIMEOUT_OPTIONS,
|
||||||
DISCONNECTION_TIMEOUT_OPTIONS,
|
DISCONNECTION_TIMEOUT_OPTIONS,
|
||||||
SYNC_FAILED_WARNING_TIP,
|
SYNC_FAILED_WARNING_TIP,
|
||||||
|
COVER_SAVE_STRATEGIES,
|
||||||
} from '../shared/constants/form';
|
} from '../shared/constants/form';
|
||||||
import { RecorderSettings } from '../shared/setting.model';
|
import { RecorderSettings } from '../shared/setting.model';
|
||||||
import {
|
import {
|
||||||
@ -43,10 +44,13 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
|
|||||||
readonly streamFormatOptions = cloneDeep(STREAM_FORMAT_OPTIONS) as Mutable<
|
readonly streamFormatOptions = cloneDeep(STREAM_FORMAT_OPTIONS) as Mutable<
|
||||||
typeof STREAM_FORMAT_OPTIONS
|
typeof STREAM_FORMAT_OPTIONS
|
||||||
>;
|
>;
|
||||||
|
readonly fmp4StreamTimeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
|
||||||
|
typeof TIMEOUT_OPTIONS
|
||||||
|
>;
|
||||||
readonly qualityOptions = cloneDeep(QUALITY_OPTIONS) as Mutable<
|
readonly qualityOptions = cloneDeep(QUALITY_OPTIONS) as Mutable<
|
||||||
typeof QUALITY_OPTIONS
|
typeof QUALITY_OPTIONS
|
||||||
>;
|
>;
|
||||||
readonly timeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
|
readonly readTimeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
|
||||||
typeof TIMEOUT_OPTIONS
|
typeof TIMEOUT_OPTIONS
|
||||||
>;
|
>;
|
||||||
readonly disconnectionTimeoutOptions = cloneDeep(
|
readonly disconnectionTimeoutOptions = cloneDeep(
|
||||||
@ -55,6 +59,9 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
|
|||||||
readonly bufferOptions = cloneDeep(BUFFER_OPTIONS) as Mutable<
|
readonly bufferOptions = cloneDeep(BUFFER_OPTIONS) as Mutable<
|
||||||
typeof BUFFER_OPTIONS
|
typeof BUFFER_OPTIONS
|
||||||
>;
|
>;
|
||||||
|
readonly coverSaveStrategies = cloneDeep(COVER_SAVE_STRATEGIES) as Mutable<
|
||||||
|
typeof COVER_SAVE_STRATEGIES
|
||||||
|
>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
formBuilder: FormBuilder,
|
formBuilder: FormBuilder,
|
||||||
@ -64,10 +71,12 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
|
|||||||
this.settingsForm = formBuilder.group({
|
this.settingsForm = formBuilder.group({
|
||||||
streamFormat: [''],
|
streamFormat: [''],
|
||||||
qualityNumber: [''],
|
qualityNumber: [''],
|
||||||
|
fmp4StreamTimeout: [''],
|
||||||
readTimeout: [''],
|
readTimeout: [''],
|
||||||
disconnectionTimeout: [''],
|
disconnectionTimeout: [''],
|
||||||
bufferSize: [''],
|
bufferSize: [''],
|
||||||
saveCover: [''],
|
saveCover: [''],
|
||||||
|
coverSaveStrategy: [''],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,6 +88,10 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
|
|||||||
return this.settingsForm.get('qualityNumber') as FormControl;
|
return this.settingsForm.get('qualityNumber') as FormControl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get fmp4StreamTimeoutControl() {
|
||||||
|
return this.settingsForm.get('fmp4StreamTimeout') as FormControl;
|
||||||
|
}
|
||||||
|
|
||||||
get readTimeoutControl() {
|
get readTimeoutControl() {
|
||||||
return this.settingsForm.get('readTimeout') as FormControl;
|
return this.settingsForm.get('readTimeout') as FormControl;
|
||||||
}
|
}
|
||||||
@ -95,6 +108,10 @@ export class RecorderSettingsComponent implements OnInit, OnChanges {
|
|||||||
return this.settingsForm.get('saveCover') as FormControl;
|
return this.settingsForm.get('saveCover') as FormControl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get coverSaveStrategyControl() {
|
||||||
|
return this.settingsForm.get('coverSaveStrategy') as FormControl;
|
||||||
|
}
|
||||||
|
|
||||||
ngOnChanges(): void {
|
ngOnChanges(): void {
|
||||||
this.syncStatus = mapValues(this.settings, () => true);
|
this.syncStatus = mapValues(this.settings, () => true);
|
||||||
this.settingsForm.setValue(this.settings);
|
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';
|
import range from 'lodash-es/range';
|
||||||
|
|
||||||
@ -52,9 +52,14 @@ export const DELETE_STRATEGIES = [
|
|||||||
{ label: '从不', value: DeleteStrategy.NEVER },
|
{ label: '从不', value: DeleteStrategy.NEVER },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const COVER_SAVE_STRATEGIES = [
|
||||||
|
{ label: '默认', value: CoverSaveStrategy.DEFAULT },
|
||||||
|
{ label: '去重', value: CoverSaveStrategy.DEDUP },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const STREAM_FORMAT_OPTIONS = [
|
export const STREAM_FORMAT_OPTIONS = [
|
||||||
{ label: 'FLV', value: 'flv' },
|
{ label: 'FLV', value: 'flv' },
|
||||||
{ label: 'HLS (ts)', value: 'ts' },
|
// { label: 'HLS (ts)', value: 'ts' },
|
||||||
{ label: 'HLS (fmp4)', value: 'fmp4' },
|
{ label: 'HLS (fmp4)', value: 'fmp4' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
@ -29,13 +29,20 @@ export type QualityNumber =
|
|||||||
| 150 // 高清
|
| 150 // 高清
|
||||||
| 80; // 流畅
|
| 80; // 流畅
|
||||||
|
|
||||||
|
export enum CoverSaveStrategy {
|
||||||
|
DEFAULT = 'default',
|
||||||
|
DEDUP = 'dedup',
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecorderSettings {
|
export interface RecorderSettings {
|
||||||
streamFormat: StreamFormat;
|
streamFormat: StreamFormat;
|
||||||
qualityNumber: QualityNumber;
|
qualityNumber: QualityNumber;
|
||||||
|
fmp4StreamTimeout: number;
|
||||||
readTimeout: number;
|
readTimeout: number;
|
||||||
disconnectionTimeout: number;
|
disconnectionTimeout: number;
|
||||||
bufferSize: number;
|
bufferSize: number;
|
||||||
saveCover: boolean;
|
saveCover: boolean;
|
||||||
|
coverSaveStrategy: CoverSaveStrategy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RecorderOptions = Nullable<RecorderSettings>;
|
export type RecorderOptions = Nullable<RecorderSettings>;
|
||||||
|
@ -62,11 +62,19 @@
|
|||||||
<span class="label">格式画质</span
|
<span class="label">格式画质</span
|
||||||
><span class="value">
|
><span class="value">
|
||||||
<span>
|
<span>
|
||||||
{{ data.task_status.real_stream_format }}
|
{{
|
||||||
|
data.task_status.real_stream_format
|
||||||
|
? data.task_status.real_stream_format
|
||||||
|
: "N/A"
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
<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
|
}}<ng-container *ngIf="isBlurayStreamQuality()">, bluray</ng-container
|
||||||
>)
|
>)
|
||||||
</span>
|
</span>
|
||||||
|
@ -32,10 +32,12 @@ export class TaskManagerService {
|
|||||||
return this.taskService.updateTaskInfo(roomId).pipe(
|
return this.taskService.updateTaskInfo(roomId).pipe(
|
||||||
tap(
|
tap(
|
||||||
() => {
|
() => {
|
||||||
this.message.success('成功刷新任务的数据');
|
this.message.success(`[${roomId}] 成功刷新任务的数据`);
|
||||||
},
|
},
|
||||||
(error: HttpErrorResponse) => {
|
(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(
|
return this.taskService.removeTask(roomId).pipe(
|
||||||
tap(
|
tap(
|
||||||
() => {
|
() => {
|
||||||
this.message.success('任务已删除');
|
this.message.success(`[${roomId}] 任务已删除`);
|
||||||
},
|
},
|
||||||
(error: HttpErrorResponse) => {
|
(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> {
|
startTask(roomId: number): Observable<ResponseMessage> {
|
||||||
const messageId = this.message.loading('正在运行任务...', {
|
const messageId = this.message.loading(`[${roomId}] 正在运行任务...`, {
|
||||||
nzDuration: 0,
|
nzDuration: 0,
|
||||||
}).messageId;
|
}).messageId;
|
||||||
return this.taskService.startTask(roomId).pipe(
|
return this.taskService.startTask(roomId).pipe(
|
||||||
tap(
|
tap(
|
||||||
() => {
|
() => {
|
||||||
this.message.remove(messageId);
|
this.message.remove(messageId);
|
||||||
this.message.success('成功运行任务');
|
this.message.success(`[${roomId}] 成功运行任务`);
|
||||||
},
|
},
|
||||||
(error: HttpErrorResponse) => {
|
(error: HttpErrorResponse) => {
|
||||||
this.message.remove(messageId);
|
this.message.remove(messageId);
|
||||||
this.message.error(`运行任务出错: ${error.message}`);
|
this.message.error(`[${roomId}] 运行任务出错: ${error.message}`);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@ -168,18 +170,18 @@ export class TaskManagerService {
|
|||||||
roomId: number,
|
roomId: number,
|
||||||
force: boolean = false
|
force: boolean = false
|
||||||
): Observable<ResponseMessage> {
|
): Observable<ResponseMessage> {
|
||||||
const messageId = this.message.loading('正在停止任务...', {
|
const messageId = this.message.loading(`[${roomId}] 正在停止任务...`, {
|
||||||
nzDuration: 0,
|
nzDuration: 0,
|
||||||
}).messageId;
|
}).messageId;
|
||||||
return this.taskService.stopTask(roomId, force).pipe(
|
return this.taskService.stopTask(roomId, force).pipe(
|
||||||
tap(
|
tap(
|
||||||
() => {
|
() => {
|
||||||
this.message.remove(messageId);
|
this.message.remove(messageId);
|
||||||
this.message.success('成功停止任务');
|
this.message.success(`[${roomId}] 成功停止任务`);
|
||||||
},
|
},
|
||||||
(error: HttpErrorResponse) => {
|
(error: HttpErrorResponse) => {
|
||||||
this.message.remove(messageId);
|
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> {
|
enableRecorder(roomId: number): Observable<ResponseMessage> {
|
||||||
const messageId = this.message.loading('正在开启录制...', {
|
const messageId = this.message.loading(`[${roomId}] 正在开启录制...`, {
|
||||||
nzDuration: 0,
|
nzDuration: 0,
|
||||||
}).messageId;
|
}).messageId;
|
||||||
return this.taskService.enableTaskRecorder(roomId).pipe(
|
return this.taskService.enableTaskRecorder(roomId).pipe(
|
||||||
tap(
|
tap(
|
||||||
() => {
|
() => {
|
||||||
this.message.remove(messageId);
|
this.message.remove(messageId);
|
||||||
this.message.success('成功开启录制');
|
this.message.success(`[${roomId}] 成功开启录制`);
|
||||||
},
|
},
|
||||||
(error: HttpErrorResponse) => {
|
(error: HttpErrorResponse) => {
|
||||||
this.message.remove(messageId);
|
this.message.remove(messageId);
|
||||||
this.message.error(`开启录制出错: ${error.message}`);
|
this.message.error(`[${roomId}] 开启录制出错: ${error.message}`);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@ -248,18 +250,18 @@ export class TaskManagerService {
|
|||||||
roomId: number,
|
roomId: number,
|
||||||
force: boolean = false
|
force: boolean = false
|
||||||
): Observable<ResponseMessage> {
|
): Observable<ResponseMessage> {
|
||||||
const messageId = this.message.loading('正在关闭录制...', {
|
const messageId = this.message.loading(`[${roomId}] 正在关闭录制...`, {
|
||||||
nzDuration: 0,
|
nzDuration: 0,
|
||||||
}).messageId;
|
}).messageId;
|
||||||
return this.taskService.disableTaskRecorder(roomId, force).pipe(
|
return this.taskService.disableTaskRecorder(roomId, force).pipe(
|
||||||
tap(
|
tap(
|
||||||
() => {
|
() => {
|
||||||
this.message.remove(messageId);
|
this.message.remove(messageId);
|
||||||
this.message.success('成功关闭录制');
|
this.message.success(`[${roomId}] 成功关闭录制`);
|
||||||
},
|
},
|
||||||
(error: HttpErrorResponse) => {
|
(error: HttpErrorResponse) => {
|
||||||
this.message.remove(messageId);
|
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(
|
return this.taskService.cutStream(roomId).pipe(
|
||||||
tap(
|
tap(
|
||||||
() => {
|
() => {
|
||||||
this.message.success('文件切割已触发');
|
this.message.success(`[${roomId}] 文件切割已触发`);
|
||||||
},
|
},
|
||||||
(error: HttpErrorResponse) => {
|
(error: HttpErrorResponse) => {
|
||||||
if (error.status == 403) {
|
if (error.status == 403) {
|
||||||
this.message.warning('时长太短不能切割,请稍后再试。');
|
this.message.warning(`[${roomId}] 时长太短不能切割,请稍后再试。`);
|
||||||
} else {
|
} else {
|
||||||
this.message.error(`切割文件出错: ${error.message}`);
|
this.message.error(`[${roomId}] 切割文件出错: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -91,8 +91,8 @@ export interface TaskStatus {
|
|||||||
readonly rec_rate: number;
|
readonly rec_rate: number;
|
||||||
readonly danmu_total: number;
|
readonly danmu_total: number;
|
||||||
readonly danmu_rate: number;
|
readonly danmu_rate: number;
|
||||||
readonly real_stream_format: StreamFormat;
|
readonly real_stream_format: StreamFormat | null;
|
||||||
readonly real_quality_number: QualityNumber;
|
readonly real_quality_number: QualityNumber | null;
|
||||||
readonly recording_path: string | null;
|
readonly recording_path: string | null;
|
||||||
readonly postprocessor_status: PostprocessorStatus;
|
readonly postprocessor_status: PostprocessorStatus;
|
||||||
readonly postprocessing_path: string | null;
|
readonly postprocessing_path: string | null;
|
||||||
|
@ -47,7 +47,11 @@
|
|||||||
nzTooltipTitle="录制画质"
|
nzTooltipTitle="录制画质"
|
||||||
nzTooltipPlacement="leftTop"
|
nzTooltipPlacement="leftTop"
|
||||||
>
|
>
|
||||||
{{ status.real_quality_number | quality }}
|
{{
|
||||||
|
status.real_quality_number
|
||||||
|
? (status.real_quality_number | quality)
|
||||||
|
: ""
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -29,11 +29,13 @@
|
|||||||
[nzValueTemplate]="recordingQuality"
|
[nzValueTemplate]="recordingQuality"
|
||||||
></nz-statistic>
|
></nz-statistic>
|
||||||
<ng-template #recordingQuality>{{
|
<ng-template #recordingQuality>{{
|
||||||
(taskStatus.real_quality_number | quality)! +
|
taskStatus.real_quality_number
|
||||||
|
? (taskStatus.real_quality_number | quality) +
|
||||||
" " +
|
" " +
|
||||||
"(" +
|
"(" +
|
||||||
taskStatus.real_quality_number +
|
taskStatus.real_quality_number +
|
||||||
")"
|
")"
|
||||||
|
: ""
|
||||||
}}</ng-template>
|
}}</ng-template>
|
||||||
|
|
||||||
<nz-statistic
|
<nz-statistic
|
||||||
|
@ -119,15 +119,20 @@
|
|||||||
>
|
>
|
||||||
<ng-template #streamFormatTip>
|
<ng-template #streamFormatTip>
|
||||||
<p>
|
<p>
|
||||||
选择要录制的直播流格式<br />
|
选择要录制的直播流格式
|
||||||
<br />
|
<br />
|
||||||
FLV 网络不稳定容易中断丢失数据 <br />
|
FLV: 网络不稳定容易中断丢失数据或录制到二压画质
|
||||||
HLS (ts) 基本不受本地网络影响 <br />
|
|
||||||
HLS (fmp4) 只有少数直播间支持 <br />
|
|
||||||
<br />
|
<br />
|
||||||
P.S.<br />
|
HLS (fmp4): 基本不受网络波动影响,但只有部分直播间支持。
|
||||||
非 FLV 格式需要 ffmpeg<br />
|
<br />
|
||||||
HLS (fmp4) 不支持会自动切换到 HLS (ts)<br />
|
P.S.
|
||||||
|
<br />
|
||||||
|
录制 HLS 流需要 ffmpeg
|
||||||
|
<br />
|
||||||
|
在设定时间内没有 fmp4 流会自动切换录制 flv 流
|
||||||
|
<br />
|
||||||
|
WEB 端直播播放器是 Hls7Player 的直播间支持录制 fmp4 流, fMp4Player
|
||||||
|
则不支持。
|
||||||
</p>
|
</p>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<nz-form-control class="setting-control select">
|
<nz-form-control class="setting-control select">
|
||||||
@ -150,6 +155,46 @@
|
|||||||
>覆盖全局设置</label
|
>覆盖全局设置</label
|
||||||
>
|
>
|
||||||
</nz-form-item>
|
</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-item class="setting-item">
|
||||||
<nz-form-label
|
<nz-form-label
|
||||||
class="setting-label"
|
class="setting-label"
|
||||||
@ -202,6 +247,45 @@
|
|||||||
>覆盖全局设置</label
|
>覆盖全局设置</label
|
||||||
>
|
>
|
||||||
</nz-form-item>
|
</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-item class="setting-item">
|
||||||
<nz-form-label
|
<nz-form-label
|
||||||
class="setting-label"
|
class="setting-label"
|
||||||
@ -219,7 +303,7 @@
|
|||||||
name="readTimeout"
|
name="readTimeout"
|
||||||
[(ngModel)]="model.recorder.readTimeout"
|
[(ngModel)]="model.recorder.readTimeout"
|
||||||
[disabled]="options.recorder.readTimeout === null"
|
[disabled]="options.recorder.readTimeout === null"
|
||||||
[nzOptions]="timeoutOptions"
|
[nzOptions]="readTimeoutOptions"
|
||||||
>
|
>
|
||||||
</nz-select>
|
</nz-select>
|
||||||
</nz-form-control>
|
</nz-form-control>
|
||||||
|
@ -30,6 +30,7 @@ import {
|
|||||||
BUFFER_OPTIONS,
|
BUFFER_OPTIONS,
|
||||||
DELETE_STRATEGIES,
|
DELETE_STRATEGIES,
|
||||||
SPLIT_FILE_TIP,
|
SPLIT_FILE_TIP,
|
||||||
|
COVER_SAVE_STRATEGIES,
|
||||||
} from '../../settings/shared/constants/form';
|
} from '../../settings/shared/constants/form';
|
||||||
|
|
||||||
type OptionsModel = NonNullable<TaskOptions>;
|
type OptionsModel = NonNullable<TaskOptions>;
|
||||||
@ -67,10 +68,13 @@ export class TaskSettingsDialogComponent implements OnChanges {
|
|||||||
readonly streamFormatOptions = cloneDeep(STREAM_FORMAT_OPTIONS) as Mutable<
|
readonly streamFormatOptions = cloneDeep(STREAM_FORMAT_OPTIONS) as Mutable<
|
||||||
typeof STREAM_FORMAT_OPTIONS
|
typeof STREAM_FORMAT_OPTIONS
|
||||||
>;
|
>;
|
||||||
|
readonly fmp4StreamTimeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
|
||||||
|
typeof TIMEOUT_OPTIONS
|
||||||
|
>;
|
||||||
readonly qualityOptions = cloneDeep(QUALITY_OPTIONS) as Mutable<
|
readonly qualityOptions = cloneDeep(QUALITY_OPTIONS) as Mutable<
|
||||||
typeof QUALITY_OPTIONS
|
typeof QUALITY_OPTIONS
|
||||||
>;
|
>;
|
||||||
readonly timeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
|
readonly readTimeoutOptions = cloneDeep(TIMEOUT_OPTIONS) as Mutable<
|
||||||
typeof TIMEOUT_OPTIONS
|
typeof TIMEOUT_OPTIONS
|
||||||
>;
|
>;
|
||||||
readonly disconnectionTimeoutOptions = cloneDeep(
|
readonly disconnectionTimeoutOptions = cloneDeep(
|
||||||
@ -82,6 +86,9 @@ export class TaskSettingsDialogComponent implements OnChanges {
|
|||||||
readonly deleteStrategies = cloneDeep(DELETE_STRATEGIES) as Mutable<
|
readonly deleteStrategies = cloneDeep(DELETE_STRATEGIES) as Mutable<
|
||||||
typeof DELETE_STRATEGIES
|
typeof DELETE_STRATEGIES
|
||||||
>;
|
>;
|
||||||
|
readonly coverSaveStrategies = cloneDeep(COVER_SAVE_STRATEGIES) as Mutable<
|
||||||
|
typeof COVER_SAVE_STRATEGIES
|
||||||
|
>;
|
||||||
|
|
||||||
model!: OptionsModel;
|
model!: OptionsModel;
|
||||||
options!: TaskOptions;
|
options!: TaskOptions;
|
||||||
|
@ -3,5 +3,11 @@
|
|||||||
{
|
{
|
||||||
"path": "."
|
"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